Skip to main content

astroimage/analysis/
mod.rs

1/// Image analysis: FWHM, eccentricity, SNR, PSF signal.
2
3mod background;
4mod detection;
5mod fitting;
6mod metrics;
7mod snr;
8
9use std::path::Path;
10use std::sync::Arc;
11
12use anyhow::{Context, Result};
13
14use crate::formats;
15use crate::processing::color::u16_to_f32;
16use crate::processing::debayer;
17use crate::processing::stretch::find_median;
18use crate::types::{BayerPattern, ImageMetadata, PixelData};
19
20use detection::DetectionParams;
21
22/// Quantitative metrics for a single detected star.
23pub struct StarMetrics {
24    /// Subpixel centroid X.
25    pub x: f32,
26    /// Subpixel centroid Y.
27    pub y: f32,
28    /// Background-subtracted peak value (ADU).
29    pub peak: f32,
30    /// Total background-subtracted flux (ADU).
31    pub flux: f32,
32    /// FWHM along major axis (pixels).
33    pub fwhm_x: f32,
34    /// FWHM along minor axis (pixels).
35    pub fwhm_y: f32,
36    /// Geometric mean FWHM (pixels).
37    pub fwhm: f32,
38    /// Eccentricity: 0 = round, approaching 1 = elongated.
39    pub eccentricity: f32,
40    /// Per-star aperture photometry SNR.
41    pub snr: f32,
42    /// Half-flux radius (pixels).
43    pub hfr: f32,
44    /// PSF position angle in radians, counter-clockwise from +X axis.
45    /// Orientation of the major axis (fwhm_x direction).
46    /// 0.0 when Gaussian fit is disabled and star is nearly round.
47    pub theta: f32,
48}
49
50/// Full analysis result for an image.
51pub struct AnalysisResult {
52    /// Image width (after debayer if applicable).
53    pub width: usize,
54    /// Image height (after debayer if applicable).
55    pub height: usize,
56    /// Number of source channels: 1 = mono, 3 = color (after debayer).
57    pub source_channels: usize,
58    /// Global background level (ADU).
59    pub background: f32,
60    /// Background noise sigma (ADU).
61    pub noise: f32,
62    /// Actual detection threshold used (ADU above background).
63    pub detection_threshold: f32,
64    /// Total stars detected before max_stars cap.
65    pub stars_detected: usize,
66    /// Per-star metrics, sorted by flux descending, capped at max_stars.
67    pub stars: Vec<StarMetrics>,
68    /// Median FWHM across all measured stars (pixels).
69    pub median_fwhm: f32,
70    /// Median eccentricity across all measured stars.
71    pub median_eccentricity: f32,
72    /// Median per-star SNR.
73    pub median_snr: f32,
74    /// Median half-flux radius (pixels).
75    pub median_hfr: f32,
76    /// Image-wide SNR in decibels: 20 × log10(mean_signal / noise). Comparable to PixInsight SNRViews.
77    pub snr_db: f32,
78    /// PixInsight-style SNR weight: (MeanDev / noise)².
79    pub snr_weight: f32,
80    /// PSF signal: median(star_peaks) / noise.
81    pub psf_signal: f32,
82    /// Rayleigh R² statistic for directional coherence of star position angles.
83    /// 0.0 = uniform (no trail), 1.0 = all stars aligned (strong trail).
84    /// Computed from detection-stage stamp moments on 2θ.
85    pub trail_r_squared: f32,
86    /// True if the image is likely trailed, based on the Rayleigh test.
87    /// Uses configurable R² threshold (default 0.5) and eccentricity gate.
88    pub possibly_trailed: bool,
89}
90
91/// Builder configuration for analysis (internal).
92pub struct AnalysisConfig {
93    detection_sigma: f32,
94    min_star_area: usize,
95    max_star_area: usize,
96    saturation_fraction: f32,
97    max_stars: usize,
98    use_gaussian_fit: bool,
99    background_mesh_size: Option<usize>,
100    apply_debayer: bool,
101    max_eccentricity: f32,
102    trail_r_squared_threshold: f32,
103}
104
105/// Image analyzer with builder pattern.
106pub struct ImageAnalyzer {
107    config: AnalysisConfig,
108    thread_pool: Option<Arc<rayon::ThreadPool>>,
109}
110
111impl ImageAnalyzer {
112    pub fn new() -> Self {
113        ImageAnalyzer {
114            config: AnalysisConfig {
115                detection_sigma: 5.0,
116                min_star_area: 5,
117                max_star_area: 2000,
118                saturation_fraction: 0.95,
119                max_stars: 200,
120                use_gaussian_fit: true,
121                background_mesh_size: None,
122                apply_debayer: true,
123                max_eccentricity: 1.0,
124                trail_r_squared_threshold: 0.5,
125            },
126            thread_pool: None,
127        }
128    }
129
130    /// Star detection threshold in σ above background.
131    pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
132        self.config.detection_sigma = sigma.max(1.0);
133        self
134    }
135
136    /// Reject connected components with fewer pixels than this (filters hot pixels).
137    pub fn with_min_star_area(mut self, area: usize) -> Self {
138        self.config.min_star_area = area.max(1);
139        self
140    }
141
142    /// Reject connected components with more pixels than this (filters galaxies/nebulae).
143    pub fn with_max_star_area(mut self, area: usize) -> Self {
144        self.config.max_star_area = area;
145        self
146    }
147
148    /// Reject stars with peak > fraction × 65535 (saturated).
149    pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
150        self.config.saturation_fraction = frac.clamp(0.5, 1.0);
151        self
152    }
153
154    /// Analyze only the brightest N stars.
155    pub fn with_max_stars(mut self, n: usize) -> Self {
156        self.config.max_stars = n.max(1);
157        self
158    }
159
160    /// Use fast windowed-moments instead of Gaussian fit for FWHM (less accurate but faster).
161    pub fn without_gaussian_fit(mut self) -> Self {
162        self.config.use_gaussian_fit = false;
163        self
164    }
165
166    /// Enable mesh-grid background estimation with given cell size (handles gradients).
167    pub fn with_background_mesh(mut self, cell_size: usize) -> Self {
168        self.config.background_mesh_size = Some(cell_size.max(16));
169        self
170    }
171
172    /// Skip debayering for OSC images (less accurate but faster).
173    pub fn without_debayer(mut self) -> Self {
174        self.config.apply_debayer = false;
175        self
176    }
177
178    /// Reject stars with eccentricity above this threshold (filters elongated objects/trails).
179    /// Default: 0.5. Set to 1.0 to disable.
180    pub fn with_max_eccentricity(mut self, ecc: f32) -> Self {
181        self.config.max_eccentricity = ecc.clamp(0.0, 1.0);
182        self
183    }
184
185    /// Set the R² threshold for trail detection.
186    /// Images with Rayleigh R² above this are flagged as possibly trailed.
187    /// Default: 0.5. Lower values are more aggressive (more false positives).
188    pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
189        self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
190        self
191    }
192
193    /// Use a custom rayon thread pool.
194    pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
195        self.thread_pool = Some(pool);
196        self
197    }
198
199    /// Analyze a FITS or XISF image file.
200    pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
201        let path = path.as_ref();
202        match &self.thread_pool {
203            Some(pool) => pool.install(|| self.analyze_impl(path)),
204            None => self.analyze_impl(path),
205        }
206    }
207
208    /// Analyze pre-loaded f32 pixel data.
209    ///
210    /// `data`: planar f32 pixel data (for 3-channel: RRRGGGBBB layout).
211    /// `width`: image width.
212    /// `height`: image height.
213    /// `channels`: 1 for mono, 3 for RGB.
214    pub fn analyze_data(
215        &self,
216        data: &[f32],
217        width: usize,
218        height: usize,
219        channels: usize,
220    ) -> Result<AnalysisResult> {
221        match &self.thread_pool {
222            Some(pool) => pool.install(|| {
223                self.run_analysis(data, width, height, channels, None)
224            }),
225            None => self.run_analysis(data, width, height, channels, None),
226        }
227    }
228
229    /// Analyze pre-read raw pixel data (skips file I/O).
230    ///
231    /// Accepts `ImageMetadata` and borrows `PixelData`, handling u16→f32
232    /// conversion and green-channel interpolation for OSC images internally.
233    pub fn analyze_raw(
234        &self,
235        meta: &ImageMetadata,
236        pixels: &PixelData,
237    ) -> Result<AnalysisResult> {
238        match &self.thread_pool {
239            Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
240            None => self.analyze_raw_impl(meta, pixels),
241        }
242    }
243
244    fn analyze_raw_impl(
245        &self,
246        meta: &ImageMetadata,
247        pixels: &PixelData,
248    ) -> Result<AnalysisResult> {
249        let f32_data = match pixels {
250            PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
251            PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
252        };
253
254        let mut data = f32_data.into_owned();
255        let width = meta.width;
256        let height = meta.height;
257        let channels = meta.channels;
258
259        let green_mask = if self.config.apply_debayer
260            && meta.bayer_pattern != BayerPattern::None
261            && channels == 1
262        {
263            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
264            Some(build_green_mask(width, height, meta.bayer_pattern))
265        } else {
266            None
267        };
268
269        self.run_analysis(&data, width, height, channels, green_mask.as_deref())
270    }
271
272    fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
273        let (meta, pixel_data) =
274            formats::read_image(path).context("Failed to read image for analysis")?;
275
276        // Convert to f32
277        let f32_data = match pixel_data {
278            PixelData::Float32(d) => d,
279            PixelData::Uint16(d) => u16_to_f32(&d),
280        };
281
282        let mut data = f32_data;
283        let width = meta.width;
284        let height = meta.height;
285        let channels = meta.channels;
286
287        // Green-channel interpolation for OSC: native-resolution mono green image
288        // (matches Siril's interpolate_nongreen — no PSF broadening from 2×2 binning)
289        let green_mask = if self.config.apply_debayer
290            && meta.bayer_pattern != BayerPattern::None
291            && channels == 1
292        {
293            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
294            // width, height, channels unchanged — native resolution mono green
295            Some(build_green_mask(width, height, meta.bayer_pattern))
296        } else {
297            None
298        };
299
300        self.run_analysis(&data, width, height, channels, green_mask.as_deref())
301    }
302
303    fn run_analysis(
304        &self,
305        data: &[f32],
306        width: usize,
307        height: usize,
308        channels: usize,
309        green_mask: Option<&[bool]>,
310    ) -> Result<AnalysisResult> {
311        // Extract luminance if multi-channel
312        let lum = if channels == 3 {
313            extract_luminance(data, width, height)
314        } else {
315            data[..width * height].to_vec()
316        };
317
318        // Background estimation
319        let bg_result = if let Some(cell_size) = self.config.background_mesh_size {
320            background::estimate_background_mesh(&lum, width, height, cell_size)
321        } else {
322            background::estimate_background(&lum, width, height)
323        };
324
325        let bg_map_ref = bg_result.background_map.as_deref();
326
327        // Star detection
328        let det_params = DetectionParams {
329            detection_sigma: self.config.detection_sigma,
330            min_star_area: self.config.min_star_area,
331            max_star_area: self.config.max_star_area,
332            saturation_limit: self.config.saturation_fraction * 65535.0,
333            max_stars: self.config.max_stars,
334        };
335
336        let detected = detection::detect_stars(
337            &lum,
338            width,
339            height,
340            bg_result.background,
341            bg_result.noise,
342            bg_map_ref,
343            &det_params,
344        );
345
346        let stars_detected = detected.len();
347        let detection_threshold = self.config.detection_sigma * bg_result.noise;
348
349        // ── Phase 1: Image-level trailing detection (Rayleigh test) ─────
350        // Compute R² and advisory flag; never reject — let callers decide.
351        //
352        // Two paths flag trailing via Rayleigh test on 2θ:
353        //
354        // Path A — Strong R² (> threshold): Flag regardless of eccentricity.
355        //   Real trails produce R² > 0.7 (strong directional coherence).
356        //   Grid-induced coherence gives R² ≈ 0.15 (undersampled) to 0.40
357        //   (oversampled with field-angle effects like coma/curvature).
358        //
359        // Path B — Eccentricity-gated (median ecc > 0.6): standard Rayleigh.
360        //   For undersampled stars, moments are noisy and theta has grid bias.
361        //   Only flag when blobs are genuinely elongated (ecc > 0.6).
362        let (trail_r_squared, possibly_trailed) = if detected.len() >= 5 {
363            let n = detected.len();
364            let (sum_cos, sum_sin) =
365                detected
366                    .iter()
367                    .fold((0.0f64, 0.0f64), |(sc, ss), s| {
368                        let a = 2.0 * s.theta as f64;
369                        (sc + a.cos(), ss + a.sin())
370                    });
371            let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
372            let p = (-(n as f64) * r_sq).exp();
373
374            let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
375            eccs.sort_unstable_by(|a, b| a.total_cmp(b));
376            let median_ecc = eccs[eccs.len() / 2];
377
378            let threshold = self.config.trail_r_squared_threshold as f64;
379            let trailed = (r_sq > threshold && p < 0.01)
380                || (median_ecc > 0.6 && p < 0.05);
381
382            (r_sq as f32, trailed)
383        } else {
384            (0.0, false)
385        };
386
387        let zero_result = || {
388            Ok(AnalysisResult {
389                width,
390                height,
391                source_channels: channels,
392                background: bg_result.background,
393                noise: bg_result.noise,
394                detection_threshold,
395                stars_detected,
396                stars: Vec::new(),
397                median_fwhm: 0.0,
398                median_eccentricity: 0.0,
399                median_snr: 0.0,
400                median_hfr: 0.0,
401                snr_db: snr::compute_snr_db(&lum, bg_result.noise),
402                snr_weight: snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise),
403                psf_signal: 0.0,
404                trail_r_squared,
405                possibly_trailed,
406            })
407        };
408
409        if stars_detected == 0 {
410            return zero_result();
411        }
412
413        // Measure PSF metrics
414        let mut measured = metrics::measure_stars(
415            &lum,
416            width,
417            height,
418            &detected,
419            bg_result.background,
420            bg_map_ref,
421            self.config.use_gaussian_fit,
422            green_mask,
423        );
424
425        if measured.is_empty() {
426            return zero_result();
427        }
428
429        // ── Phase 2: Per-star eccentricity filter (after measurement) ───
430        measured.retain(|s| s.eccentricity <= self.config.max_eccentricity);
431
432        if measured.is_empty() {
433            return zero_result();
434        }
435
436        // Compute median FWHM for aperture sizing
437        let mut fwhm_vals: Vec<f32> = measured.iter().map(|s| s.fwhm).collect();
438        let median_fwhm = find_median(&mut fwhm_vals);
439
440        // Per-star SNR
441        snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
442
443        // Summary statistics
444        let mut ecc_vals: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
445        let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
446        let mut hfr_vals: Vec<f32> = measured.iter().map(|s| s.hfr).collect();
447
448        let median_eccentricity = find_median(&mut ecc_vals);
449        let median_snr = find_median(&mut snr_vals);
450        let median_hfr = find_median(&mut hfr_vals);
451
452        let snr_db = snr::compute_snr_db(&lum, bg_result.noise);
453        let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
454        let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
455
456        // Convert to public StarMetrics
457        let stars: Vec<StarMetrics> = measured
458            .into_iter()
459            .map(|m| StarMetrics {
460                x: m.x,
461                y: m.y,
462                peak: m.peak,
463                flux: m.flux,
464                fwhm_x: m.fwhm_x,
465                fwhm_y: m.fwhm_y,
466                fwhm: m.fwhm,
467                eccentricity: m.eccentricity,
468                snr: m.snr,
469                hfr: m.hfr,
470                theta: m.theta,
471            })
472            .collect();
473
474        Ok(AnalysisResult {
475            width,
476            height,
477            source_channels: channels,
478            background: bg_result.background,
479            noise: bg_result.noise,
480            detection_threshold,
481            stars_detected,
482            stars,
483            median_fwhm,
484            median_eccentricity,
485            median_snr,
486            median_hfr,
487            snr_db,
488            snr_weight,
489            psf_signal,
490            trail_r_squared,
491            possibly_trailed,
492        })
493    }
494}
495
496impl Default for ImageAnalyzer {
497    fn default() -> Self {
498        Self::new()
499    }
500}
501
502/// Build a boolean mask marking green CFA pixel positions.
503///
504/// Returns a `Vec<bool>` of length `width * height` where `true` marks pixels
505/// that are at green positions in the Bayer pattern. For GBRG/GRBG green is
506/// at (row + col) even; for RGGB/BGGR green is at (row + col) odd.
507fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
508    let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
509    (0..height)
510        .flat_map(|y| {
511            (0..width).map(move |x| {
512                let parity = (x + y) & 1;
513                if green_at_even { parity == 0 } else { parity == 1 }
514            })
515        })
516        .collect()
517}
518
519/// Extract luminance from planar RGB data: L = 0.2126R + 0.7152G + 0.0722B
520fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
521    use rayon::prelude::*;
522
523    let plane_size = width * height;
524    let r = &data[..plane_size];
525    let g = &data[plane_size..2 * plane_size];
526    let b = &data[2 * plane_size..3 * plane_size];
527
528    let mut lum = vec![0.0_f32; plane_size];
529    const CHUNK: usize = 8192;
530    lum.par_chunks_mut(CHUNK)
531        .enumerate()
532        .for_each(|(ci, chunk)| {
533            let off = ci * CHUNK;
534            for (i, dst) in chunk.iter_mut().enumerate() {
535                let idx = off + i;
536                *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
537            }
538        });
539    lum
540}