Skip to main content

astroimage/analysis/
mod.rs

1/// Image analysis: FWHM, eccentricity, SNR, PSF signal.
2
3#[cfg(feature = "debug-pipeline")]
4pub mod background;
5#[cfg(not(feature = "debug-pipeline"))]
6mod background;
7
8#[cfg(feature = "debug-pipeline")]
9pub mod convolution;
10#[cfg(not(feature = "debug-pipeline"))]
11mod convolution;
12
13#[cfg(feature = "debug-pipeline")]
14pub mod detection;
15#[cfg(not(feature = "debug-pipeline"))]
16mod detection;
17
18#[cfg(feature = "debug-pipeline")]
19pub mod fitting;
20#[cfg(not(feature = "debug-pipeline"))]
21mod fitting;
22
23#[cfg(feature = "debug-pipeline")]
24pub mod metrics;
25#[cfg(not(feature = "debug-pipeline"))]
26mod metrics;
27
28#[cfg(feature = "debug-pipeline")]
29pub mod snr;
30#[cfg(not(feature = "debug-pipeline"))]
31mod snr;
32
33#[cfg(feature = "debug-pipeline")]
34pub mod render;
35
36use std::path::Path;
37use std::sync::Arc;
38
39use anyhow::{Context, Result};
40
41use crate::formats;
42use crate::processing::color::u16_to_f32;
43use crate::processing::debayer;
44use crate::processing::stretch::find_median;
45use crate::types::{BayerPattern, ImageMetadata, PixelData};
46
47use detection::DetectionParams;
48
49/// Method used to measure this star's PSF.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum FitMethod {
52    /// Free-beta Moffat (8 params) — highest accuracy.
53    FreeMoffat,
54    /// Fixed-beta Moffat (7 params) — field median beta.
55    FixedMoffat,
56    /// Gaussian fallback (7 params).
57    Gaussian,
58    /// Windowed moments — lowest accuracy, flagged unreliable.
59    Moments,
60}
61
62/// Quantitative metrics for a single detected star.
63pub struct StarMetrics {
64    /// Subpixel centroid X.
65    pub x: f32,
66    /// Subpixel centroid Y.
67    pub y: f32,
68    /// Background-subtracted peak value (ADU).
69    pub peak: f32,
70    /// Total background-subtracted flux (ADU).
71    pub flux: f32,
72    /// FWHM along major axis (pixels).
73    pub fwhm_x: f32,
74    /// FWHM along minor axis (pixels).
75    pub fwhm_y: f32,
76    /// Geometric mean FWHM (pixels).
77    pub fwhm: f32,
78    /// Eccentricity: 0 = round, approaching 1 = elongated.
79    pub eccentricity: f32,
80    /// Per-star aperture photometry SNR.
81    pub snr: f32,
82    /// Half-flux radius (pixels).
83    pub hfr: f32,
84    /// PSF position angle in radians, counter-clockwise from +X axis.
85    /// Orientation of the major axis (fwhm_x direction).
86    /// 0.0 when Gaussian fit is disabled and star is nearly round.
87    pub theta: f32,
88    /// Moffat β parameter (None if Gaussian/moments fit was used).
89    pub beta: Option<f32>,
90    /// Which PSF fitting method produced this measurement.
91    pub fit_method: FitMethod,
92    /// Normalized fit residual (quality weight: w = 1/(1+r)).
93    /// Lower = better fit. 1.0 for moments fallback.
94    pub fit_residual: f32,
95}
96
97/// Per-stage timing in milliseconds for the analysis pipeline.
98pub struct StageTiming {
99    pub background_ms: f64,
100    pub detection_pass1_ms: f64,
101    pub calibration_ms: f64,
102    pub detection_pass2_ms: f64,
103    pub measurement_ms: f64,
104    pub snr_ms: f64,
105    pub statistics_ms: f64,
106    pub total_ms: f64,
107}
108
109/// Full analysis result for an image.
110pub struct AnalysisResult {
111    /// Image width (after debayer if applicable).
112    pub width: usize,
113    /// Image height (after debayer if applicable).
114    pub height: usize,
115    /// Number of source channels: 1 = mono, 3 = color (after debayer).
116    pub source_channels: usize,
117    /// Global background level (ADU).
118    pub background: f32,
119    /// Background noise sigma (ADU).
120    pub noise: f32,
121    /// Actual detection threshold used (ADU above background).
122    pub detection_threshold: f32,
123    /// Total stars detected (raw detection count, before measure cap).
124    pub stars_detected: usize,
125    /// Per-star metrics, sorted by flux descending, capped at max_stars.
126    pub stars: Vec<StarMetrics>,
127    /// Median FWHM across all measured stars (pixels).
128    pub median_fwhm: f32,
129    /// Median eccentricity across all measured stars.
130    pub median_eccentricity: f32,
131    /// Median per-star SNR.
132    pub median_snr: f32,
133    /// Median half-flux radius (pixels).
134    pub median_hfr: f32,
135    /// SNR weight for frame ranking: (MeanDev / noise)².
136    pub snr_weight: f32,
137    /// PSF signal: median(star_peaks) / noise.
138    pub psf_signal: f32,
139    /// Per-frame SNR: background / noise (linear ratio).
140    /// Use for stacking prediction: stacked_snr = sqrt(sum(frame_snr_i²)).
141    pub frame_snr: f32,
142    /// Rayleigh R̄² (squared mean resultant length) for directional coherence
143    /// of star position angles. Uses 2θ doubling for axial orientation data.
144    /// 0.0 = uniform (no trail), 1.0 = all stars aligned (strong trail).
145    /// A threshold of 0.5 corresponds to R̄ ≈ 0.71 (strong coherence).
146    pub trail_r_squared: f32,
147    /// True if the image is likely trailed, based on the Rayleigh test.
148    /// Fires when R̄² > threshold with significant p-value, or when R̄² > 0.05
149    /// with high median eccentricity (catches non-coherent guiding issues).
150    /// Requires ≥20 detected stars for statistical reliability.
151    pub possibly_trailed: bool,
152    /// Measured FWHM from adaptive two-pass detection (pixels).
153    /// This is the FWHM used for the final matched filter kernel.
154    /// If the first-pass FWHM was within 30% of 3.0, this equals 3.0.
155    pub measured_fwhm_kernel: f32,
156    /// Median Moffat β across all stars (None if Moffat fitting not used).
157    /// Typical range: 2.0-5.0 for real optics. Lower = broader wings.
158    pub median_beta: Option<f32>,
159    /// Per-stage timing breakdown for the analysis pipeline.
160    pub stage_timing: StageTiming,
161}
162
163/// Builder configuration for analysis (internal).
164pub struct AnalysisConfig {
165    detection_sigma: f32,
166    min_star_area: usize,
167    max_star_area: usize,
168    saturation_fraction: f32,
169    max_stars: usize,
170    apply_debayer: bool,
171    trail_r_squared_threshold: f32,
172    /// MRS wavelet noise layers (default 4).
173    noise_layers: usize,
174    /// Max stars to PSF-fit for statistics. 0 = measure all.
175    measure_cap: usize,
176    /// LM max iterations for pass-2 measurement fits.
177    fit_max_iter: usize,
178    /// LM convergence tolerance for pass-2 measurement fits.
179    fit_tolerance: f64,
180    /// Consecutive LM step rejects before early bailout.
181    fit_max_rejects: usize,
182}
183
184/// Image analyzer with builder pattern.
185pub struct ImageAnalyzer {
186    config: AnalysisConfig,
187    thread_pool: Option<Arc<rayon::ThreadPool>>,
188}
189
190impl ImageAnalyzer {
191    pub fn new() -> Self {
192        ImageAnalyzer {
193            config: AnalysisConfig {
194                detection_sigma: 5.0,
195                min_star_area: 5,
196                max_star_area: 2000,
197                saturation_fraction: 0.95,
198                max_stars: 200,
199                apply_debayer: true,
200                trail_r_squared_threshold: 0.5,
201                noise_layers: 0,
202                measure_cap: 500,
203                fit_max_iter: 25,
204                fit_tolerance: 1e-4,
205                fit_max_rejects: 5,
206            },
207            thread_pool: None,
208        }
209    }
210
211    /// Star detection threshold in σ above background.
212    pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
213        self.config.detection_sigma = sigma.max(1.0);
214        self
215    }
216
217    /// Reject connected components with fewer pixels than this (filters hot pixels).
218    pub fn with_min_star_area(mut self, area: usize) -> Self {
219        self.config.min_star_area = area.max(1);
220        self
221    }
222
223    /// Reject connected components with more pixels than this (filters galaxies/nebulae).
224    pub fn with_max_star_area(mut self, area: usize) -> Self {
225        self.config.max_star_area = area;
226        self
227    }
228
229    /// Reject stars with peak > fraction × 65535 (saturated).
230    pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
231        self.config.saturation_fraction = frac.clamp(0.5, 1.0);
232        self
233    }
234
235    /// Keep only the brightest N stars in the returned result.
236    pub fn with_max_stars(mut self, n: usize) -> Self {
237        self.config.max_stars = n.max(1);
238        self
239    }
240
241    /// Skip debayering for OSC images (less accurate but faster).
242    pub fn without_debayer(mut self) -> Self {
243        self.config.apply_debayer = false;
244        self
245    }
246
247    /// Set the R² threshold for trail detection.
248    /// Images with Rayleigh R² above this are flagged as possibly trailed.
249    /// Default: 0.5. Lower values are more aggressive (more false positives).
250    pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
251        self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
252        self
253    }
254
255    /// Set MRS wavelet noise layers for noise estimation.
256    /// Default: 0 (fast MAD noise from mesh-grid cell sigmas).
257    /// Set to 1-6 for MRS wavelet noise (more robust against nebulosity/gradients,
258    /// ~200ms slower per frame). 4 is the recommended MRS setting.
259    pub fn with_mrs_layers(mut self, layers: usize) -> Self {
260        self.config.noise_layers = layers;
261        self
262    }
263
264    /// Max stars to PSF-fit for statistics. Default 2000.
265    /// Stars are sorted by flux (brightest first) before capping.
266    /// Set to 0 to measure all detected stars (catalog export mode).
267    pub fn with_measure_cap(mut self, n: usize) -> Self {
268        self.config.measure_cap = n;
269        self
270    }
271
272    /// LM max iterations for pass-2 measurement fits. Default 25.
273    /// Calibration pass always uses 50 iterations.
274    pub fn with_fit_max_iter(mut self, n: usize) -> Self {
275        self.config.fit_max_iter = n.max(1);
276        self
277    }
278
279    /// LM convergence tolerance for pass-2 measurement fits. Default 1e-4.
280    /// Calibration pass always uses 1e-6.
281    pub fn with_fit_tolerance(mut self, tol: f64) -> Self {
282        self.config.fit_tolerance = tol;
283        self
284    }
285
286    /// Consecutive LM step rejects before early bailout. Default 5.
287    pub fn with_fit_max_rejects(mut self, n: usize) -> Self {
288        self.config.fit_max_rejects = n.max(1);
289        self
290    }
291
292    /// Use a custom rayon thread pool.
293    pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
294        self.thread_pool = Some(pool);
295        self
296    }
297
298    /// Analyze a FITS or XISF image file.
299    pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
300        let path = path.as_ref();
301        match &self.thread_pool {
302            Some(pool) => pool.install(|| self.analyze_impl(path)),
303            None => self.analyze_impl(path),
304        }
305    }
306
307    /// Analyze pre-loaded f32 pixel data.
308    ///
309    /// `data`: planar f32 pixel data (for 3-channel: RRRGGGBBB layout).
310    /// `width`: image width.
311    /// `height`: image height.
312    /// `channels`: 1 for mono, 3 for RGB.
313    pub fn analyze_data(
314        &self,
315        data: &[f32],
316        width: usize,
317        height: usize,
318        channels: usize,
319    ) -> Result<AnalysisResult> {
320        match &self.thread_pool {
321            Some(pool) => pool.install(|| {
322                self.run_analysis(data, width, height, channels)
323            }),
324            None => self.run_analysis(data, width, height, channels),
325        }
326    }
327
328    /// Analyze pre-read raw pixel data (skips file I/O).
329    ///
330    /// Accepts `ImageMetadata` and borrows `PixelData`, handling u16→f32
331    /// conversion and green-channel interpolation for OSC images internally.
332    pub fn analyze_raw(
333        &self,
334        meta: &ImageMetadata,
335        pixels: &PixelData,
336    ) -> Result<AnalysisResult> {
337        match &self.thread_pool {
338            Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
339            None => self.analyze_raw_impl(meta, pixels),
340        }
341    }
342
343    /// Analyze multiple images in parallel.
344    ///
345    /// `concurrency` controls how many frames are analyzed simultaneously.
346    /// `progress` is called after each frame completes with (completed, total, path).
347    /// Returns results in approximate completion order.
348    pub fn analyze_batch<P, F>(
349        &self,
350        paths: &[P],
351        concurrency: usize,
352        progress: F,
353    ) -> Vec<(std::path::PathBuf, Result<AnalysisResult>)>
354    where
355        P: AsRef<std::path::Path> + Sync,
356        F: Fn(usize, usize, &std::path::Path) + Send + Sync,
357    {
358        use std::sync::atomic::{AtomicUsize, Ordering};
359        use rayon::prelude::*;
360
361        let total = paths.len();
362        let completed = AtomicUsize::new(0);
363        let concurrency = concurrency.max(1);
364
365        let do_batch = || {
366            let mut results = Vec::with_capacity(total);
367            for chunk in paths.chunks(concurrency) {
368                let chunk_results: Vec<_> = chunk
369                    .into_par_iter()
370                    .map(|p| {
371                        let path = p.as_ref();
372                        let result = self.analyze_impl(path);
373                        let n = completed.fetch_add(1, Ordering::Relaxed) + 1;
374                        progress(n, total, path);
375                        (path.to_path_buf(), result)
376                    })
377                    .collect();
378                results.extend(chunk_results);
379            }
380            results
381        };
382
383        match &self.thread_pool {
384            Some(pool) => pool.install(do_batch),
385            None => do_batch(),
386        }
387    }
388
389    fn analyze_raw_impl(
390        &self,
391        meta: &ImageMetadata,
392        pixels: &PixelData,
393    ) -> Result<AnalysisResult> {
394        let f32_data = match pixels {
395            PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
396            PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
397        };
398
399        let mut data = f32_data.into_owned();
400        let width = meta.width;
401        let height = meta.height;
402        let channels = meta.channels;
403
404        // OSC green interpolation: replace R/B pixels with weighted average of
405        // neighboring green values.  PSF fitting uses all pixels — no green mask.
406        if self.config.apply_debayer
407            && meta.bayer_pattern != BayerPattern::None
408            && channels == 1
409        {
410            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
411        }
412
413        self.run_analysis(&data, width, height, channels)
414    }
415
416    fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
417        let (meta, pixel_data) =
418            formats::read_image(path).context("Failed to read image for analysis")?;
419
420        // Convert to f32
421        let f32_data = match pixel_data {
422            PixelData::Float32(d) => d,
423            PixelData::Uint16(d) => u16_to_f32(&d),
424        };
425
426        let mut data = f32_data;
427        let width = meta.width;
428        let height = meta.height;
429        let channels = meta.channels;
430
431        // OSC green interpolation for Bayer data.
432        if self.config.apply_debayer
433            && meta.bayer_pattern != BayerPattern::None
434            && channels == 1
435        {
436            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
437        }
438
439        self.run_analysis(&data, width, height, channels)
440    }
441
442    fn run_analysis(
443        &self,
444        data: &[f32],
445        width: usize,
446        height: usize,
447        channels: usize,
448    ) -> Result<AnalysisResult> {
449        let pipeline_start = std::time::Instant::now();
450
451        // Extract luminance if multi-channel
452        let lum = if channels == 3 {
453            extract_luminance(data, width, height)
454        } else {
455            data[..width * height].to_vec()
456        };
457
458        let det_params = DetectionParams {
459            detection_sigma: self.config.detection_sigma,
460            min_star_area: self.config.min_star_area,
461            max_star_area: self.config.max_star_area,
462            saturation_limit: self.config.saturation_fraction * 65535.0,
463        };
464
465        // ── Stage 1: Background & Noise ──────────────────────────────────
466        let t = std::time::Instant::now();
467        let cell_size = background::auto_cell_size(width, height);
468        let mut bg_result = background::estimate_background_mesh(&lum, width, height, cell_size);
469        if self.config.noise_layers > 0 {
470            // MRS wavelet noise: accurate but ~500ms. Layers 1-6.
471            bg_result.noise = background::estimate_noise_mrs(
472                &lum, width, height, self.config.noise_layers.max(1),
473            ).max(0.001);
474        }
475        // noise_layers == 0: keep MAD noise from mesh-grid (already in bg_result.noise)
476        let background_ms = t.elapsed().as_secs_f64() * 1000.0;
477
478        // ── Stage 2, Pass 1: Discovery ───────────────────────────────────
479        let t = std::time::Instant::now();
480        let initial_fwhm = 3.0_f32;
481        let pass1_stars = {
482            let bg_map = bg_result.background_map.as_deref();
483            let noise_map = bg_result.noise_map.as_deref();
484            detection::detect_stars(
485                &lum, width, height,
486                bg_result.background, bg_result.noise,
487                bg_map, noise_map, &det_params, initial_fwhm,
488                None,
489            )
490        };
491        let detection_pass1_ms = t.elapsed().as_secs_f64() * 1000.0;
492
493        // Select calibration stars: brightest, not saturated, not too elongated
494        let t = std::time::Instant::now();
495        let calibration_stars: Vec<&detection::DetectedStar> = pass1_stars
496            .iter()
497            .filter(|s| s.eccentricity < 0.5 && s.area >= 5)
498            .take(100)
499            .collect();
500
501        // Free-beta Moffat on calibration stars to discover field PSF model
502        let field_beta: Option<f64>;
503        let field_fwhm: f32;
504        if calibration_stars.len() >= 3 {
505            let cal_owned: Vec<detection::DetectedStar> = calibration_stars
506                .iter()
507                .map(|s| detection::DetectedStar {
508                    x: s.x, y: s.y, peak: s.peak, flux: s.flux,
509                    area: s.area, theta: s.theta, eccentricity: s.eccentricity,
510                })
511                .collect();
512            let cal_measured = metrics::measure_stars(
513                &lum, width, height, &cal_owned,
514                bg_result.background,
515                bg_result.background_map.as_deref(),
516                None, // fit all pixels in green-interpolated image (no green mask)
517                None, // free-beta Moffat
518                50, 1e-6, 5, // calibration always uses full precision
519                None,   // no screening for calibration
520                false,  // not trailed
521            );
522
523            let mut beta_vals: Vec<f32> = cal_measured.iter().filter_map(|s| s.beta).collect();
524            let mut fwhm_vals: Vec<f32> = cal_measured.iter().map(|s| s.fwhm).collect();
525
526            if beta_vals.len() >= 3 {
527                field_beta = Some(sigma_clipped_median(&beta_vals) as f64);
528            } else if !beta_vals.is_empty() {
529                field_beta = Some(find_median(&mut beta_vals) as f64);
530            } else {
531                field_beta = None;
532            }
533
534            if fwhm_vals.len() >= 3 {
535                field_fwhm = sigma_clipped_median(&fwhm_vals);
536            } else if !fwhm_vals.is_empty() {
537                field_fwhm = find_median(&mut fwhm_vals);
538            } else {
539                field_fwhm = estimate_fwhm_from_stars(
540                    &lum, width, height, &pass1_stars,
541                    bg_result.background, bg_result.background_map.as_deref(),
542                );
543            }
544        } else {
545            // Too few calibration stars — fall back to halfmax estimate
546            field_beta = None;
547            field_fwhm = estimate_fwhm_from_stars(
548                &lum, width, height, &pass1_stars,
549                bg_result.background, bg_result.background_map.as_deref(),
550            );
551        }
552
553        let calibration_ms = t.elapsed().as_secs_f64() * 1000.0;
554
555        // Source-mask background re-estimation skipped for speed.
556        // The sigma-clipped cell stats already reject >30% star-contaminated
557        // cells and 3-round sigma clipping handles residual star flux within
558        // cells. The source mask adds ~400ms for marginal improvement.
559
560        // ── Stage 2, Pass 2: Full detection with refined kernel ──────────
561        let t = std::time::Instant::now();
562        // Clamp minimum FWHM to 2.0px — no real optics produce sub-2px stars,
563        // and tiny kernels have poor noise rejection (OSC green-channel fits
564        // can underestimate FWHM due to Bayer grid undersampling).
565        let clamped_fwhm = field_fwhm.max(2.0);
566        let final_fwhm = if clamped_fwhm > 1.0
567            && ((clamped_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
568        {
569            clamped_fwhm.min(initial_fwhm * 2.0)
570        } else {
571            initial_fwhm
572        };
573
574        let detected = {
575            let bg_map = bg_result.background_map.as_deref();
576            let noise_map = bg_result.noise_map.as_deref();
577            detection::detect_stars(
578                &lum, width, height,
579                bg_result.background, bg_result.noise,
580                bg_map, noise_map, &det_params, final_fwhm,
581                Some(clamped_fwhm),
582            )
583        };
584        let detection_pass2_ms = t.elapsed().as_secs_f64() * 1000.0;
585
586        let bg_map_ref = bg_result.background_map.as_deref();
587        let detection_threshold = self.config.detection_sigma * bg_result.noise;
588
589        // ── Trail detection (Rayleigh test on detection-stage moments) ────
590        // Uses 2θ doubling for axial orientation data. R̄² = squared mean
591        // resultant length; p ≈ exp(-n·R̄²) is the asymptotic Rayleigh p-value.
592        let (trail_r_squared, possibly_trailed) = if detected.len() >= 20 {
593            let n = detected.len();
594            let (sum_cos, sum_sin) =
595                detected.iter().fold((0.0f64, 0.0f64), |(sc, ss), s| {
596                    let a = 2.0 * s.theta as f64;
597                    (sc + a.cos(), ss + a.sin())
598                });
599            let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
600            let p = (-(n as f64) * r_sq).exp();
601            let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
602            eccs.sort_unstable_by(|a, b| a.total_cmp(b));
603            let median_ecc = if eccs.len() % 2 == 1 {
604                eccs[eccs.len() / 2]
605            } else {
606                (eccs[eccs.len() / 2 - 1] + eccs[eccs.len() / 2]) * 0.5
607            };
608            let threshold = self.config.trail_r_squared_threshold as f64;
609            let trailed = (r_sq > threshold && p < 0.01)       // strong angle coherence
610                || (r_sq > 0.05 && median_ecc > 0.6 && p < 0.05); // moderate coherence + high ecc
611            (r_sq as f32, trailed)
612        } else {
613            (0.0, false)
614        };
615
616        let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
617        let frame_snr = if bg_result.noise > 0.0 { bg_result.background / bg_result.noise } else { 0.0 };
618
619        let make_zero_result = |stars_detected: usize| {
620            Ok(AnalysisResult {
621                width, height, source_channels: channels,
622                background: bg_result.background, noise: bg_result.noise,
623                detection_threshold, stars_detected,
624                stars: Vec::new(),
625                median_fwhm: 0.0, median_eccentricity: 0.0,
626                median_snr: 0.0, median_hfr: 0.0,
627                snr_weight, psf_signal: 0.0, frame_snr,
628                trail_r_squared, possibly_trailed,
629                measured_fwhm_kernel: final_fwhm,
630                median_beta: field_beta.map(|b| b as f32),
631                stage_timing: StageTiming {
632                    background_ms: 0.0, detection_pass1_ms: 0.0, calibration_ms: 0.0,
633                    detection_pass2_ms: 0.0, measurement_ms: 0.0, snr_ms: 0.0,
634                    statistics_ms: 0.0, total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
635                },
636            })
637        };
638
639        if detected.is_empty() {
640            return make_zero_result(0);
641        }
642
643        // ── Stage 3: PSF Measurement (with measure cap) ─────────────────
644        let t = std::time::Instant::now();
645        let stars_detected = detected.len();
646
647        // Apply measure cap with spatial grid balancing.
648        // Divide image into 4×4 grid, round-robin select from each cell
649        // to ensure spatial coverage across the field.
650        let effective_cap = if self.config.measure_cap == 0 {
651            detected.len()
652        } else {
653            self.config.measure_cap
654        };
655
656        let to_measure: Vec<detection::DetectedStar> = if detected.len() <= effective_cap {
657            detected.clone()
658        } else {
659            debug_assert!(
660                detected.windows(2).all(|w| w[0].flux >= w[1].flux),
661                "detected stars must be sorted by flux descending"
662            );
663            const GRID_N: usize = 4;
664            let cell_w = width as f32 / GRID_N as f32;
665            let cell_h = height as f32 / GRID_N as f32;
666            let mut buckets: Vec<Vec<&detection::DetectedStar>> =
667                vec![Vec::new(); GRID_N * GRID_N];
668
669            for star in &detected {
670                let gx = ((star.x / cell_w) as usize).min(GRID_N - 1);
671                let gy = ((star.y / cell_h) as usize).min(GRID_N - 1);
672                buckets[gy * GRID_N + gx].push(star);
673            }
674
675            let mut selected: Vec<detection::DetectedStar> = Vec::with_capacity(effective_cap);
676            let mut idx = vec![0usize; GRID_N * GRID_N];
677            loop {
678                let mut added_any = false;
679                for cell in 0..(GRID_N * GRID_N) {
680                    if selected.len() >= effective_cap { break; }
681                    if idx[cell] < buckets[cell].len() {
682                        selected.push(buckets[cell][idx[cell]].clone());
683                        idx[cell] += 1;
684                        added_any = true;
685                    }
686                }
687                if !added_any || selected.len() >= effective_cap { break; }
688            }
689            selected
690        };
691
692        let mut measured = metrics::measure_stars(
693            &lum, width, height, &to_measure,
694            bg_result.background, bg_map_ref,
695            None, field_beta, // fit all pixels in green-interpolated image
696            self.config.fit_max_iter,
697            self.config.fit_tolerance,
698            self.config.fit_max_rejects,
699            Some(field_fwhm),     // enable moments screening
700            possibly_trailed,      // bypass ecc gate on trailed frames
701        );
702        let measurement_ms = t.elapsed().as_secs_f64() * 1000.0;
703
704        if measured.is_empty() {
705            return make_zero_result(stars_detected);
706        }
707
708        // ── Stage 4: Metrics ─────────────────────────────────────────────
709        let t = std::time::Instant::now();
710
711        // Trail detection uses only the Rayleigh test (Stage 1) on detection-stage
712        // angles.  High PSF eccentricity alone is NOT trailing — it can be optical
713        // aberration (coma, tilt) or wind shake without coherent direction.
714
715        // FWHM & HFR: ecc ≤ 0.8 filter — elongated profiles inflate
716        // geometric-mean FWHM. On trailed frames bypass it.
717        const FWHM_ECC_MAX: f32 = 0.8;
718        let fwhm_filtered: Vec<&metrics::MeasuredStar> = if possibly_trailed {
719            measured.iter().collect()
720        } else {
721            let round: Vec<&metrics::MeasuredStar> = measured.iter()
722                .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
723                .collect();
724            if round.len() >= 3 { round } else { measured.iter().collect() }
725        };
726        let (fwhm_vals, hfr_vals, shape_weights) = (
727            fwhm_filtered.iter().map(|s| s.fwhm).collect::<Vec<f32>>(),
728            fwhm_filtered.iter().map(|s| s.hfr).collect::<Vec<f32>>(),
729            fwhm_filtered.iter().map(|s| 1.0 / (1.0 + s.fit_residual)).collect::<Vec<f32>>(),
730        );
731        let median_fwhm = sigma_clipped_weighted_median(&fwhm_vals, &shape_weights);
732
733        // Eccentricity: on normal frames, ecc ≤ 0.8 cutoff removes noise from
734        // faint detections. On trailed frames, elongation IS the signal — bypass
735        // the cutoff so the reported ecc reflects actual frame quality.
736        let ecc_use_all = possibly_trailed;
737        let ecc_filtered: Vec<&metrics::MeasuredStar> = if ecc_use_all {
738            measured.iter().collect()
739        } else {
740            let filtered: Vec<&metrics::MeasuredStar> = measured.iter()
741                .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
742                .collect();
743            if filtered.len() >= 3 { filtered } else { measured.iter().collect() }
744        };
745        let ecc_vals: Vec<f32> = ecc_filtered.iter().map(|s| s.eccentricity).collect();
746        let ecc_weights: Vec<f32> = ecc_filtered.iter()
747            .map(|s| 1.0 / (1.0 + s.fit_residual))
748            .collect();
749
750        let statistics_ms_before_snr = t.elapsed().as_secs_f64() * 1000.0;
751
752        let t = std::time::Instant::now();
753        snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
754        let snr_ms = t.elapsed().as_secs_f64() * 1000.0;
755
756        let t = std::time::Instant::now();
757        let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
758
759        let median_eccentricity = sigma_clipped_weighted_median(&ecc_vals, &ecc_weights);
760        let median_snr = find_median(&mut snr_vals);
761        let median_hfr = sigma_clipped_weighted_median(&hfr_vals, &shape_weights);
762        let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
763
764        // Median beta: use field_beta from calibration, or compute from all stars
765        let median_beta = if let Some(fb) = field_beta {
766            Some(fb as f32)
767        } else {
768            let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
769            if beta_vals.is_empty() { None } else { Some(find_median(&mut beta_vals)) }
770        };
771
772        // Late cap: truncate to max_stars AFTER all statistics are computed
773        measured.truncate(self.config.max_stars);
774
775        let stars: Vec<StarMetrics> = measured
776            .into_iter()
777            .map(|m| StarMetrics {
778                x: m.x, y: m.y, peak: m.peak, flux: m.flux,
779                fwhm_x: m.fwhm_x, fwhm_y: m.fwhm_y, fwhm: m.fwhm,
780                eccentricity: m.eccentricity, snr: m.snr, hfr: m.hfr,
781                theta: m.theta, beta: m.beta, fit_method: m.fit_method,
782                fit_residual: m.fit_residual,
783            })
784            .collect();
785        let statistics_ms = statistics_ms_before_snr + t.elapsed().as_secs_f64() * 1000.0;
786        let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
787
788        Ok(AnalysisResult {
789            width, height, source_channels: channels,
790            background: bg_result.background, noise: bg_result.noise,
791            detection_threshold, stars_detected, stars,
792            median_fwhm, median_eccentricity, median_snr, median_hfr,
793            snr_weight, psf_signal, frame_snr,
794            trail_r_squared, possibly_trailed,
795            measured_fwhm_kernel: final_fwhm,
796            median_beta,
797            stage_timing: StageTiming {
798                background_ms, detection_pass1_ms, calibration_ms,
799                detection_pass2_ms, measurement_ms, snr_ms,
800                statistics_ms, total_ms,
801            },
802        })
803    }
804}
805
806impl Default for ImageAnalyzer {
807    fn default() -> Self {
808        Self::new()
809    }
810}
811
812/// Sigma-clipped median: 2-iteration, 3σ MAD-based clipping.
813///
814/// Standard in SExtractor/DAOPHOT for robust statistics:
815///   MAD = median(|x_i − median|)
816///   σ_MAD = 1.4826 × MAD
817///   reject: |x − median| > 3 × σ_MAD
818///
819/// Returns plain median if fewer than 3 values remain after clipping.
820pub fn sigma_clipped_median(values: &[f32]) -> f32 {
821    if values.is_empty() {
822        return 0.0;
823    }
824    let mut v: Vec<f32> = values.to_vec();
825    for _ in 0..2 {
826        if v.len() < 3 {
827            break;
828        }
829        let med = find_median(&mut v);
830        let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
831        let mad = find_median(&mut abs_devs);
832        let sigma_mad = 1.4826 * mad;
833        if sigma_mad < 1e-10 {
834            break; // all values identical
835        }
836        let clip = 3.0 * sigma_mad;
837        v.retain(|&x| (x - med).abs() <= clip);
838    }
839    if v.is_empty() {
840        return find_median(&mut values.to_vec());
841    }
842    find_median(&mut v)
843}
844
845/// Weighted median: walk sorted (value, weight) pairs until cumulative weight >= total/2.
846///
847/// Returns 0.0 if inputs are empty or total weight is zero.
848pub fn weighted_median(values: &[f32], weights: &[f32]) -> f32 {
849    if values.is_empty() || values.len() != weights.len() {
850        return 0.0;
851    }
852    let mut pairs: Vec<(f32, f32)> = values.iter().copied()
853        .zip(weights.iter().copied())
854        .filter(|(_, w)| *w > 0.0)
855        .collect();
856    if pairs.is_empty() {
857        return 0.0;
858    }
859    pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
860    let total: f32 = pairs.iter().map(|(_, w)| w).sum();
861    if total <= 0.0 {
862        return 0.0;
863    }
864    let half = total * 0.5;
865    let mut cumulative = 0.0_f32;
866    for &(val, w) in &pairs {
867        cumulative += w;
868        if cumulative >= half {
869            return val;
870        }
871    }
872    pairs.last().unwrap().0
873}
874
875/// Sigma-clipped weighted median: 2-iteration 3σ MAD clipping, then weighted median.
876///
877/// Combines outlier rejection (via MAD) with continuous quality weighting.
878/// Falls back to plain weighted median if fewer than 3 values survive clipping.
879pub fn sigma_clipped_weighted_median(values: &[f32], weights: &[f32]) -> f32 {
880    if values.is_empty() || values.len() != weights.len() {
881        return 0.0;
882    }
883    let mut v: Vec<f32> = values.to_vec();
884    let mut w: Vec<f32> = weights.to_vec();
885    for _ in 0..2 {
886        if v.len() < 3 {
887            break;
888        }
889        let med = weighted_median(&v, &w);
890        let abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
891        // Unweighted MAD for clipping threshold (weights affect median, not clip boundary)
892        let mut sorted_devs = abs_devs.clone();
893        sorted_devs.sort_by(|a, b| a.total_cmp(b));
894        let mad = sorted_devs[sorted_devs.len() / 2];
895        let sigma_mad = 1.4826 * mad;
896        if sigma_mad < 1e-10 {
897            break;
898        }
899        let clip = 3.0 * sigma_mad;
900        let mut new_v = Vec::with_capacity(v.len());
901        let mut new_w = Vec::with_capacity(w.len());
902        for (val, wt) in v.iter().zip(w.iter()) {
903            if (*val - med).abs() <= clip {
904                new_v.push(*val);
905                new_w.push(*wt);
906            }
907        }
908        v = new_v;
909        w = new_w;
910    }
911    if v.is_empty() {
912        return weighted_median(values, weights);
913    }
914    weighted_median(&v, &w)
915}
916
917/// Estimate FWHM from the brightest detected stars by extracting stamps
918/// and using `estimate_sigma_halfmax`. Returns median FWHM, or 0.0 if
919/// fewer than 3 stars yield valid measurements.
920pub fn estimate_fwhm_from_stars(
921    lum: &[f32],
922    width: usize,
923    height: usize,
924    stars: &[detection::DetectedStar],
925    background: f32,
926    bg_map: Option<&[f32]>,
927) -> f32 {
928    // Scan top 50 brightest (already sorted by flux descending), select up to 20
929    // with low eccentricity (≤ 0.7) to avoid elongated non-stellar objects
930    // poisoning the kernel estimate.
931    let scan_n = stars.len().min(50);
932    if scan_n < 3 {
933        return 0.0;
934    }
935
936    let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
937        .iter()
938        .filter(|s| s.eccentricity <= 0.7)
939        .take(20)
940        .collect();
941    if round_stars.len() < 3 {
942        return 0.0;
943    }
944
945    let mut fwhm_vals = Vec::with_capacity(round_stars.len());
946    for star in &round_stars {
947        let stamp_radius = 12_usize; // enough for FWHM up to ~10px
948        let cx = star.x.round() as i32;
949        let cy = star.y.round() as i32;
950        let sr = stamp_radius as i32;
951        if cx - sr <= 0 || cy - sr <= 0
952            || cx + sr >= width as i32 - 1
953            || cy + sr >= height as i32 - 1
954        {
955            continue;
956        }
957        let x0 = (cx - sr) as usize;
958        let y0 = (cy - sr) as usize;
959        let x1 = (cx + sr) as usize;
960        let y1 = (cy + sr) as usize;
961        let stamp_w = x1 - x0 + 1;
962        let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
963        for sy in y0..=y1 {
964            for sx in x0..=x1 {
965                let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
966                stamp.push(lum[sy * width + sx] - bg);
967            }
968        }
969        let rel_cx = star.x - x0 as f32;
970        let rel_cy = star.y - y0 as f32;
971        let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
972        let fwhm = sigma * 2.3548;
973        if fwhm > 1.0 && fwhm < 20.0 {
974            fwhm_vals.push(fwhm);
975        }
976    }
977
978    if fwhm_vals.len() < 3 {
979        return 0.0;
980    }
981    find_median(&mut fwhm_vals)
982}
983
984/// Build a boolean mask marking green CFA pixel positions.
985///
986/// Returns a `Vec<bool>` of length `width * height` where `true` marks pixels
987/// Extract luminance from planar RGB data: L = 0.2126R + 0.7152G + 0.0722B
988pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
989    use rayon::prelude::*;
990
991    let plane_size = width * height;
992    let r = &data[..plane_size];
993    let g = &data[plane_size..2 * plane_size];
994    let b = &data[2 * plane_size..3 * plane_size];
995
996    let mut lum = vec![0.0_f32; plane_size];
997    const CHUNK: usize = 8192;
998    lum.par_chunks_mut(CHUNK)
999        .enumerate()
1000        .for_each(|(ci, chunk)| {
1001            let off = ci * CHUNK;
1002            for (i, dst) in chunk.iter_mut().enumerate() {
1003                let idx = off + i;
1004                *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
1005            }
1006        });
1007    lum
1008}
1009
1010/// Prepare luminance data from raw metadata + pixels.
1011///
1012/// Handles u16→f32 conversion, green-channel interpolation for OSC,
1013/// and luminance extraction for multi-channel images.
1014/// Returns `(luminance, width, height, channels, green_mask)`.
1015#[cfg(feature = "debug-pipeline")]
1016pub fn prepare_luminance(
1017    meta: &crate::types::ImageMetadata,
1018    pixels: &crate::types::PixelData,
1019    apply_debayer: bool,
1020) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
1021    use crate::processing::color::u16_to_f32;
1022    use crate::processing::debayer;
1023
1024    let f32_data = match pixels {
1025        PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
1026        PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
1027    };
1028
1029    let mut data = f32_data.into_owned();
1030    let width = meta.width;
1031    let height = meta.height;
1032    let channels = meta.channels;
1033
1034    // OSC green interpolation (matching Siril's interpolate_nongreen).
1035    // No green mask — PSF fitting uses all pixels in the interpolated image.
1036    if apply_debayer
1037        && meta.bayer_pattern != BayerPattern::None
1038        && channels == 1
1039    {
1040        data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
1041    }
1042    let green_mask: Option<Vec<bool>> = None;
1043
1044    let lum = if channels == 3 {
1045        extract_luminance(&data, width, height)
1046    } else {
1047        data[..width * height].to_vec()
1048    };
1049
1050    (lum, width, height, channels, green_mask)
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056
1057    #[test]
1058    fn test_weighted_median_equal_weights() {
1059        // Equal weights → same as unweighted median
1060        let vals = [1.0_f32, 3.0, 5.0, 7.0, 9.0];
1061        let wts = [1.0_f32; 5];
1062        let wm = weighted_median(&vals, &wts);
1063        assert!((wm - 5.0).abs() < 0.01, "Equal-weight median should be 5, got {}", wm);
1064    }
1065
1066    #[test]
1067    fn test_weighted_median_skewed_weights() {
1068        // Heavy weight on low value should pull median down
1069        let vals = [1.0_f32, 10.0];
1070        let wts = [9.0_f32, 1.0]; // 90% weight on 1.0
1071        let wm = weighted_median(&vals, &wts);
1072        assert!((wm - 1.0).abs() < 0.01, "Skewed-weight median should be 1, got {}", wm);
1073    }
1074
1075    #[test]
1076    fn test_weighted_median_empty() {
1077        let wm = weighted_median(&[], &[]);
1078        assert_eq!(wm, 0.0);
1079    }
1080
1081    #[test]
1082    fn test_weighted_median_single() {
1083        let wm = weighted_median(&[42.0], &[1.0]);
1084        assert!((wm - 42.0).abs() < 0.01);
1085    }
1086
1087    #[test]
1088    fn test_sigma_clipped_weighted_median_basic() {
1089        // With an outlier, sigma clipping should reject it
1090        let vals = [3.0_f32, 3.1, 3.0, 3.2, 3.0, 100.0]; // 100.0 is outlier
1091        let wts = [1.0_f32; 6];
1092        let scwm = sigma_clipped_weighted_median(&vals, &wts);
1093        assert!(scwm < 4.0, "Outlier should be clipped, got {}", scwm);
1094    }
1095}