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