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