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/// Quantitative metrics for a single detected star.
50pub struct StarMetrics {
51    /// Subpixel centroid X.
52    pub x: f32,
53    /// Subpixel centroid Y.
54    pub y: f32,
55    /// Background-subtracted peak value (ADU).
56    pub peak: f32,
57    /// Total background-subtracted flux (ADU).
58    pub flux: f32,
59    /// FWHM along major axis (pixels).
60    pub fwhm_x: f32,
61    /// FWHM along minor axis (pixels).
62    pub fwhm_y: f32,
63    /// Geometric mean FWHM (pixels).
64    pub fwhm: f32,
65    /// Eccentricity: 0 = round, approaching 1 = elongated.
66    pub eccentricity: f32,
67    /// Per-star aperture photometry SNR.
68    pub snr: f32,
69    /// Half-flux radius (pixels).
70    pub hfr: f32,
71    /// PSF position angle in radians, counter-clockwise from +X axis.
72    /// Orientation of the major axis (fwhm_x direction).
73    /// 0.0 when Gaussian fit is disabled and star is nearly round.
74    pub theta: f32,
75    /// Moffat β parameter (None if Gaussian/moments fit was used).
76    pub beta: Option<f32>,
77}
78
79/// Full analysis result for an image.
80pub struct AnalysisResult {
81    /// Image width (after debayer if applicable).
82    pub width: usize,
83    /// Image height (after debayer if applicable).
84    pub height: usize,
85    /// Number of source channels: 1 = mono, 3 = color (after debayer).
86    pub source_channels: usize,
87    /// Global background level (ADU).
88    pub background: f32,
89    /// Background noise sigma (ADU).
90    pub noise: f32,
91    /// Actual detection threshold used (ADU above background).
92    pub detection_threshold: f32,
93    /// Total stars with valid PSF measurements (statistics computed from all).
94    pub stars_detected: usize,
95    /// Per-star metrics, sorted by flux descending, capped at max_stars.
96    pub stars: Vec<StarMetrics>,
97    /// Median FWHM across all measured stars (pixels).
98    pub median_fwhm: f32,
99    /// Median eccentricity across all measured stars.
100    pub median_eccentricity: f32,
101    /// Median per-star SNR.
102    pub median_snr: f32,
103    /// Median half-flux radius (pixels).
104    pub median_hfr: f32,
105    /// Image-wide SNR in decibels: 20 × log10(mean_signal / noise).
106    pub snr_db: f32,
107    /// SNR weight for frame ranking: (MeanDev / noise)².
108    pub snr_weight: f32,
109    /// PSF signal: median(star_peaks) / noise.
110    pub psf_signal: f32,
111    /// Rayleigh R² statistic for directional coherence of star position angles.
112    /// 0.0 = uniform (no trail), 1.0 = all stars aligned (strong trail).
113    /// Computed from detection-stage stamp moments on 2θ.
114    pub trail_r_squared: f32,
115    /// True if the image is likely trailed, based on the Rayleigh test.
116    /// Uses configurable R² threshold (default 0.5) and eccentricity gate.
117    pub possibly_trailed: bool,
118    /// Measured FWHM from adaptive two-pass detection (pixels).
119    /// This is the FWHM used for the final matched filter kernel.
120    /// If the first-pass FWHM was within 30% of 3.0, this equals 3.0.
121    pub measured_fwhm_kernel: f32,
122    /// Median Moffat β across all stars (None if Moffat fitting not used).
123    /// Typical range: 2.0-5.0 for real optics. Lower = broader wings.
124    pub median_beta: Option<f32>,
125}
126
127/// Builder configuration for analysis (internal).
128pub struct AnalysisConfig {
129    detection_sigma: f32,
130    min_star_area: usize,
131    max_star_area: usize,
132    saturation_fraction: f32,
133    max_measure: Option<usize>,
134    max_stars: usize,
135    use_gaussian_fit: bool,
136    background_mesh_size: Option<usize>,
137    apply_debayer: bool,
138    trail_r_squared_threshold: f32,
139    use_moffat_fit: bool,
140    iterative_background: usize,
141    /// MRS wavelet noise layers: 0 = legacy MAD (default), 1+ = MRS wavelet.
142    noise_layers: usize,
143    /// Fixed Moffat beta: None = free (default), Some(b) = fixed.
144    moffat_beta: Option<f32>,
145    /// Maximum distortion (eccentricity) filter: None = no filter (default).
146    max_distortion: Option<f32>,
147}
148
149/// Image analyzer with builder pattern.
150pub struct ImageAnalyzer {
151    config: AnalysisConfig,
152    thread_pool: Option<Arc<rayon::ThreadPool>>,
153}
154
155impl ImageAnalyzer {
156    pub fn new() -> Self {
157        ImageAnalyzer {
158            config: AnalysisConfig {
159                detection_sigma: 5.0,
160                min_star_area: 5,
161                max_star_area: 2000,
162                saturation_fraction: 0.95,
163                max_measure: None,
164                max_stars: 200,
165                use_gaussian_fit: true,
166                background_mesh_size: None,
167                apply_debayer: true,
168                trail_r_squared_threshold: 0.5,
169                use_moffat_fit: true,
170                iterative_background: 1,
171                noise_layers: 0,
172                moffat_beta: None,
173                max_distortion: None,
174            },
175            thread_pool: None,
176        }
177    }
178
179    /// Star detection threshold in σ above background.
180    pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
181        self.config.detection_sigma = sigma.max(1.0);
182        self
183    }
184
185    /// Reject connected components with fewer pixels than this (filters hot pixels).
186    pub fn with_min_star_area(mut self, area: usize) -> Self {
187        self.config.min_star_area = area.max(1);
188        self
189    }
190
191    /// Reject connected components with more pixels than this (filters galaxies/nebulae).
192    pub fn with_max_star_area(mut self, area: usize) -> Self {
193        self.config.max_star_area = area;
194        self
195    }
196
197    /// Reject stars with peak > fraction × 65535 (saturated).
198    pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
199        self.config.saturation_fraction = frac.clamp(0.5, 1.0);
200        self
201    }
202
203    /// Limit the number of stars measured for PSF fitting.
204    /// Brightest N detected stars are measured; rest are skipped.
205    /// Statistics are computed from all measured stars.
206    /// Default: None (measure all). E.g. 5000 for faster dense fields.
207    pub fn with_max_measure(mut self, n: usize) -> Self {
208        self.config.max_measure = Some(n.max(100));
209        self
210    }
211
212    /// Keep only the brightest N stars in the returned result.
213    pub fn with_max_stars(mut self, n: usize) -> Self {
214        let n = n.max(1);
215        // Clamp to max_measure if set
216        self.config.max_stars = match self.config.max_measure {
217            Some(mm) => n.min(mm),
218            None => n,
219        };
220        self
221    }
222
223    /// Use fast windowed-moments instead of Gaussian fit for FWHM (less accurate but faster).
224    pub fn without_gaussian_fit(mut self) -> Self {
225        self.config.use_gaussian_fit = false;
226        self
227    }
228
229    /// Enable mesh-grid background estimation with given cell size (handles gradients).
230    pub fn with_background_mesh(mut self, cell_size: usize) -> Self {
231        self.config.background_mesh_size = Some(cell_size.max(16));
232        self
233    }
234
235    /// Skip debayering for OSC images (less accurate but faster).
236    pub fn without_debayer(mut self) -> Self {
237        self.config.apply_debayer = false;
238        self
239    }
240
241    /// Set the R² threshold for trail detection.
242    /// Images with Rayleigh R² above this are flagged as possibly trailed.
243    /// Default: 0.5. Lower values are more aggressive (more false positives).
244    pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
245        self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
246        self
247    }
248
249    /// Enable iterative source-masked background estimation.
250    /// After initial detection, source pixels are masked and background is re-estimated.
251    /// Default: 1 (no iteration). Set to 2-3 for images with many bright sources.
252    /// Requires `with_background_mesh` to be set (no-op with global background).
253    pub fn with_iterative_background(mut self, iterations: usize) -> Self {
254        self.config.iterative_background = iterations.max(1);
255        self
256    }
257
258    /// Enable MRS (Multiresolution Support) wavelet noise estimation.
259    /// Uses à trous B3-spline wavelet to isolate noise from nebulosity/gradients.
260    /// `layers`: number of wavelet layers (1 is sufficient).
261    /// Default: 0 (sigma-clipped MAD).
262    pub fn with_mrs_noise(mut self, layers: usize) -> Self {
263        self.config.noise_layers = layers;
264        self
265    }
266
267    /// Fix Moffat beta to a constant value during PSF fitting.
268    /// Removes the beta/axis-ratio tradeoff, improving FWHM stability.
269    /// Typical value: 4.0 (well-corrected optics).
270    /// Default: None (free beta).
271    pub fn with_moffat_beta(mut self, beta: f32) -> Self {
272        self.config.moffat_beta = Some(beta);
273        self
274    }
275
276    /// Reject detected stars with eccentricity above this threshold before measurement.
277    /// Pre-filters elongated candidates (optical ghosts, diffraction spikes, edge effects).
278    /// Typical value: 0.6. Default: None (no filter).
279    pub fn with_max_distortion(mut self, ecc: f32) -> Self {
280        self.config.max_distortion = Some(ecc.clamp(0.0, 1.0));
281        self
282    }
283
284    /// Use Moffat PSF fitting instead of Gaussian for more accurate wing modeling.
285    /// Falls back to Gaussian on non-convergence. Reports per-star β values.
286    /// This is the default since v0.7.0.
287    pub fn with_moffat_fit(mut self) -> Self {
288        self.config.use_moffat_fit = true;
289        self
290    }
291
292    /// Disable Moffat PSF fitting; use Gaussian fit instead.
293    pub fn without_moffat_fit(mut self) -> Self {
294        self.config.use_moffat_fit = false;
295        self
296    }
297
298    /// Use a custom rayon thread pool.
299    pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
300        self.thread_pool = Some(pool);
301        self
302    }
303
304    /// Analyze a FITS or XISF image file.
305    pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
306        let path = path.as_ref();
307        match &self.thread_pool {
308            Some(pool) => pool.install(|| self.analyze_impl(path)),
309            None => self.analyze_impl(path),
310        }
311    }
312
313    /// Analyze pre-loaded f32 pixel data.
314    ///
315    /// `data`: planar f32 pixel data (for 3-channel: RRRGGGBBB layout).
316    /// `width`: image width.
317    /// `height`: image height.
318    /// `channels`: 1 for mono, 3 for RGB.
319    pub fn analyze_data(
320        &self,
321        data: &[f32],
322        width: usize,
323        height: usize,
324        channels: usize,
325    ) -> Result<AnalysisResult> {
326        match &self.thread_pool {
327            Some(pool) => pool.install(|| {
328                self.run_analysis(data, width, height, channels, None)
329            }),
330            None => self.run_analysis(data, width, height, channels, None),
331        }
332    }
333
334    /// Analyze pre-read raw pixel data (skips file I/O).
335    ///
336    /// Accepts `ImageMetadata` and borrows `PixelData`, handling u16→f32
337    /// conversion and green-channel interpolation for OSC images internally.
338    pub fn analyze_raw(
339        &self,
340        meta: &ImageMetadata,
341        pixels: &PixelData,
342    ) -> Result<AnalysisResult> {
343        match &self.thread_pool {
344            Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
345            None => self.analyze_raw_impl(meta, pixels),
346        }
347    }
348
349    fn analyze_raw_impl(
350        &self,
351        meta: &ImageMetadata,
352        pixels: &PixelData,
353    ) -> Result<AnalysisResult> {
354        let f32_data = match pixels {
355            PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
356            PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
357        };
358
359        let mut data = f32_data.into_owned();
360        let width = meta.width;
361        let height = meta.height;
362        let channels = meta.channels;
363
364        let green_mask = if self.config.apply_debayer
365            && meta.bayer_pattern != BayerPattern::None
366            && channels == 1
367        {
368            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
369            Some(build_green_mask(width, height, meta.bayer_pattern))
370        } else {
371            None
372        };
373
374        self.run_analysis(&data, width, height, channels, green_mask.as_deref())
375    }
376
377    fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
378        let (meta, pixel_data) =
379            formats::read_image(path).context("Failed to read image for analysis")?;
380
381        // Convert to f32
382        let f32_data = match pixel_data {
383            PixelData::Float32(d) => d,
384            PixelData::Uint16(d) => u16_to_f32(&d),
385        };
386
387        let mut data = f32_data;
388        let width = meta.width;
389        let height = meta.height;
390        let channels = meta.channels;
391
392        // Green-channel interpolation for OSC: native-resolution mono green image
393        // (matches Siril's interpolate_nongreen — no PSF broadening from 2×2 binning)
394        let green_mask = if self.config.apply_debayer
395            && meta.bayer_pattern != BayerPattern::None
396            && channels == 1
397        {
398            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
399            // width, height, channels unchanged — native resolution mono green
400            Some(build_green_mask(width, height, meta.bayer_pattern))
401        } else {
402            None
403        };
404
405        self.run_analysis(&data, width, height, channels, green_mask.as_deref())
406    }
407
408    fn run_analysis(
409        &self,
410        data: &[f32],
411        width: usize,
412        height: usize,
413        channels: usize,
414        green_mask: Option<&[bool]>,
415    ) -> Result<AnalysisResult> {
416        // Extract luminance if multi-channel
417        let lum = if channels == 3 {
418            extract_luminance(data, width, height)
419        } else {
420            data[..width * height].to_vec()
421        };
422
423        // Star detection parameters
424        let det_params = DetectionParams {
425            detection_sigma: self.config.detection_sigma,
426            min_star_area: self.config.min_star_area,
427            max_star_area: self.config.max_star_area,
428            saturation_limit: self.config.saturation_fraction * 65535.0,
429        };
430
431        // Iterative background estimation + detection loop
432        let n_iterations = if self.config.background_mesh_size.is_some() {
433            self.config.iterative_background
434        } else {
435            1 // no iteration without mesh background
436        };
437
438        let mut bg_result = if let Some(cell_size) = self.config.background_mesh_size {
439            background::estimate_background_mesh(&lum, width, height, cell_size)
440        } else {
441            background::estimate_background(&lum, width, height)
442        };
443
444        // MRS wavelet noise override
445        if self.config.noise_layers > 0 {
446            bg_result.noise = background::estimate_noise_mrs(
447                &lum, width, height, self.config.noise_layers,
448            ).max(0.001);
449        }
450
451        let mut detected;
452        let final_fwhm;
453
454        // First detection pass (always runs)
455        {
456            let bg_map_ref = bg_result.background_map.as_deref();
457            let noise_map_ref = bg_result.noise_map.as_deref();
458            let initial_fwhm = 3.0_f32;
459            let pass1 = detection::detect_stars(
460                &lum, width, height,
461                bg_result.background, bg_result.noise,
462                bg_map_ref, noise_map_ref, &det_params, initial_fwhm,
463            );
464
465            let measured_kernel_fwhm = estimate_fwhm_from_stars(
466                &lum, width, height, &pass1, bg_result.background, bg_map_ref,
467            );
468
469            // Cap second-pass kernel to 2× initial to prevent runaway cascade
470            let capped_kernel_fwhm = measured_kernel_fwhm.min(initial_fwhm * 2.0);
471
472            if capped_kernel_fwhm > 0.0
473                && ((capped_kernel_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
474            {
475                detected = detection::detect_stars(
476                    &lum, width, height,
477                    bg_result.background, bg_result.noise,
478                    bg_map_ref, noise_map_ref, &det_params, capped_kernel_fwhm,
479                );
480                final_fwhm = capped_kernel_fwhm;
481            } else {
482                detected = pass1;
483                final_fwhm = initial_fwhm;
484            }
485        }
486
487        // Iterative source-masked background re-estimation (iterations 2..n)
488        if let Some(cell_size) = self.config.background_mesh_size {
489            for _ in 1..n_iterations {
490                if detected.is_empty() {
491                    break;
492                }
493
494                // Build source mask: circle of r = 2.5 × FWHM around each star
495                let mask_radius = (2.5 * final_fwhm).ceil() as i32;
496                let mask_r_sq = (mask_radius * mask_radius) as f32;
497                let mut source_mask = vec![false; width * height];
498                for star in &detected {
499                    let cx = star.x.round() as i32;
500                    let cy = star.y.round() as i32;
501                    for dy in -mask_radius..=mask_radius {
502                        let py = cy + dy;
503                        if py < 0 || py >= height as i32 { continue; }
504                        for dx in -mask_radius..=mask_radius {
505                            let px = cx + dx;
506                            if px < 0 || px >= width as i32 { continue; }
507                            if (dx * dx + dy * dy) as f32 <= mask_r_sq {
508                                source_mask[py as usize * width + px as usize] = true;
509                            }
510                        }
511                    }
512                }
513
514                // Re-estimate background excluding masked pixels
515                bg_result = background::estimate_background_mesh_masked(
516                    &lum, width, height, cell_size, &source_mask,
517                );
518
519                // Re-apply MRS noise override
520                if self.config.noise_layers > 0 {
521                    bg_result.noise = background::estimate_noise_mrs(
522                        &lum, width, height, self.config.noise_layers,
523                    ).max(0.001);
524                }
525
526                // Re-detect with updated background and noise map
527                let bg_map_ref = bg_result.background_map.as_deref();
528                let noise_map_ref = bg_result.noise_map.as_deref();
529                detected = detection::detect_stars(
530                    &lum, width, height,
531                    bg_result.background, bg_result.noise,
532                    bg_map_ref, noise_map_ref, &det_params, final_fwhm,
533                );
534            }
535        }
536
537        let bg_map_ref = bg_result.background_map.as_deref();
538
539        let detection_threshold = self.config.detection_sigma * bg_result.noise;
540
541        // ── Phase 1: Image-level trailing detection (Rayleigh test) ─────
542        // Compute R² and advisory flag; never reject — let callers decide.
543        //
544        // Two paths flag trailing via Rayleigh test on 2θ:
545        //
546        // Path A — Strong R² (> threshold): Flag regardless of eccentricity.
547        //   Real trails produce R² > 0.7 (strong directional coherence).
548        //   Grid-induced coherence gives R² ≈ 0.15 (undersampled) to 0.40
549        //   (oversampled with field-angle effects like coma/curvature).
550        //
551        // Path B — Eccentricity-gated (median ecc > 0.6): standard Rayleigh.
552        //   For undersampled stars, moments are noisy and theta has grid bias.
553        //   Only flag when blobs are genuinely elongated (ecc > 0.6).
554        let (trail_r_squared, possibly_trailed) = if detected.len() >= 5 {
555            let n = detected.len();
556            let (sum_cos, sum_sin) =
557                detected
558                    .iter()
559                    .fold((0.0f64, 0.0f64), |(sc, ss), s| {
560                        let a = 2.0 * s.theta as f64;
561                        (sc + a.cos(), ss + a.sin())
562                    });
563            let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
564            let p = (-(n as f64) * r_sq).exp();
565
566            let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
567            eccs.sort_unstable_by(|a, b| a.total_cmp(b));
568            let median_ecc = eccs[eccs.len() / 2];
569
570            let threshold = self.config.trail_r_squared_threshold as f64;
571            let trailed = (r_sq > threshold && p < 0.01)
572                || (median_ecc > 0.6 && p < 0.05);
573
574            (r_sq as f32, trailed)
575        } else {
576            (0.0, false)
577        };
578
579        let snr_db = snr::compute_snr_db(&lum, bg_result.noise);
580        let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
581
582        // Helper for zero-star results
583        let make_zero_result = |stars_detected: usize| {
584            Ok(AnalysisResult {
585                width,
586                height,
587                source_channels: channels,
588                background: bg_result.background,
589                noise: bg_result.noise,
590                detection_threshold,
591                stars_detected,
592                stars: Vec::new(),
593                median_fwhm: 0.0,
594                median_eccentricity: 0.0,
595                median_snr: 0.0,
596                median_hfr: 0.0,
597                snr_db,
598                snr_weight,
599                psf_signal: 0.0,
600                trail_r_squared,
601                possibly_trailed,
602                measured_fwhm_kernel: final_fwhm,
603                median_beta: None,
604            })
605        };
606
607        if detected.is_empty() {
608            return make_zero_result(0);
609        }
610
611        // Optional early cap: limit stars measured for speed
612        // (detected are already sorted by flux descending)
613        if let Some(max_m) = self.config.max_measure {
614            detected.truncate(max_m);
615        }
616
617        // Eccentricity pre-filter: reject elongated candidates before measurement
618        if let Some(max_ecc) = self.config.max_distortion {
619            detected.retain(|s| s.eccentricity <= max_ecc);
620        }
621
622        // Measure PSF metrics on all (possibly capped) detected stars
623        let mut measured = metrics::measure_stars(
624            &lum,
625            width,
626            height,
627            &detected,
628            bg_result.background,
629            bg_map_ref,
630            self.config.use_gaussian_fit,
631            self.config.use_moffat_fit,
632            green_mask,
633            self.config.moffat_beta.map(|b| b as f64),
634        );
635
636        if measured.is_empty() {
637            return make_zero_result(0);
638        }
639
640        // True count of stars with valid PSF measurements
641        let stars_detected = measured.len();
642
643        // Compute statistics from ALL measured stars (no filtering, no cap)
644        // Use sigma-clipped median (2-iter, 3σ MAD) for FWHM, ecc, HFR to reject outliers
645        let fwhm_vals: Vec<f32> = measured.iter().map(|s| s.fwhm).collect();
646        let median_fwhm = sigma_clipped_median(&fwhm_vals);
647
648        // Per-star SNR on ALL measured stars
649        snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
650
651        // Summary statistics from full population
652        let ecc_vals: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
653        let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
654        let hfr_vals: Vec<f32> = measured.iter().map(|s| s.hfr).collect();
655
656        let median_eccentricity = sigma_clipped_median(&ecc_vals);
657        let median_snr = find_median(&mut snr_vals); // SNR left unclipped
658        let median_hfr = sigma_clipped_median(&hfr_vals);
659
660        let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
661
662        // Compute median beta if Moffat fitting was used
663        let median_beta = if self.config.use_moffat_fit {
664            let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
665            if beta_vals.is_empty() {
666                None
667            } else {
668                Some(find_median(&mut beta_vals))
669            }
670        } else {
671            None
672        };
673
674        // Late cap: truncate to max_stars AFTER all statistics are computed
675        measured.truncate(self.config.max_stars);
676
677        // Convert to public StarMetrics
678        let stars: Vec<StarMetrics> = measured
679            .into_iter()
680            .map(|m| StarMetrics {
681                x: m.x,
682                y: m.y,
683                peak: m.peak,
684                flux: m.flux,
685                fwhm_x: m.fwhm_x,
686                fwhm_y: m.fwhm_y,
687                fwhm: m.fwhm,
688                eccentricity: m.eccentricity,
689                snr: m.snr,
690                hfr: m.hfr,
691                theta: m.theta,
692                beta: m.beta,
693            })
694            .collect();
695
696        Ok(AnalysisResult {
697            width,
698            height,
699            source_channels: channels,
700            background: bg_result.background,
701            noise: bg_result.noise,
702            detection_threshold,
703            stars_detected,
704            stars,
705            median_fwhm,
706            median_eccentricity,
707            median_snr,
708            median_hfr,
709            snr_db,
710            snr_weight,
711            psf_signal,
712            trail_r_squared,
713            possibly_trailed,
714            measured_fwhm_kernel: final_fwhm,
715            median_beta,
716        })
717    }
718}
719
720impl Default for ImageAnalyzer {
721    fn default() -> Self {
722        Self::new()
723    }
724}
725
726/// Sigma-clipped median: 2-iteration, 3σ MAD-based clipping.
727///
728/// Standard in SExtractor/DAOPHOT for robust statistics:
729///   MAD = median(|x_i − median|)
730///   σ_MAD = 1.4826 × MAD
731///   reject: |x − median| > 3 × σ_MAD
732///
733/// Returns plain median if fewer than 3 values remain after clipping.
734pub fn sigma_clipped_median(values: &[f32]) -> f32 {
735    if values.is_empty() {
736        return 0.0;
737    }
738    let mut v: Vec<f32> = values.to_vec();
739    for _ in 0..2 {
740        if v.len() < 3 {
741            break;
742        }
743        let med = find_median(&mut v);
744        let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
745        let mad = find_median(&mut abs_devs);
746        let sigma_mad = 1.4826 * mad;
747        if sigma_mad < 1e-10 {
748            break; // all values identical
749        }
750        let clip = 3.0 * sigma_mad;
751        v.retain(|&x| (x - med).abs() <= clip);
752    }
753    if v.is_empty() {
754        return find_median(&mut values.to_vec());
755    }
756    find_median(&mut v)
757}
758
759/// Estimate FWHM from the brightest detected stars by extracting stamps
760/// and using `estimate_sigma_halfmax`. Returns median FWHM, or 0.0 if
761/// fewer than 3 stars yield valid measurements.
762pub fn estimate_fwhm_from_stars(
763    lum: &[f32],
764    width: usize,
765    height: usize,
766    stars: &[detection::DetectedStar],
767    background: f32,
768    bg_map: Option<&[f32]>,
769) -> f32 {
770    // Scan top 50 brightest (already sorted by flux descending), select up to 20
771    // with low eccentricity (≤ 0.7) to avoid elongated non-stellar objects
772    // poisoning the kernel estimate.
773    let scan_n = stars.len().min(50);
774    if scan_n < 3 {
775        return 0.0;
776    }
777
778    let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
779        .iter()
780        .filter(|s| s.eccentricity <= 0.7)
781        .take(20)
782        .collect();
783    if round_stars.len() < 3 {
784        return 0.0;
785    }
786
787    let mut fwhm_vals = Vec::with_capacity(round_stars.len());
788    for star in &round_stars {
789        let stamp_radius = 12_usize; // enough for FWHM up to ~10px
790        let cx = star.x.round() as i32;
791        let cy = star.y.round() as i32;
792        let sr = stamp_radius as i32;
793        if cx - sr <= 0 || cy - sr <= 0
794            || cx + sr >= width as i32 - 1
795            || cy + sr >= height as i32 - 1
796        {
797            continue;
798        }
799        let x0 = (cx - sr) as usize;
800        let y0 = (cy - sr) as usize;
801        let x1 = (cx + sr) as usize;
802        let y1 = (cy + sr) as usize;
803        let stamp_w = x1 - x0 + 1;
804        let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
805        for sy in y0..=y1 {
806            for sx in x0..=x1 {
807                let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
808                stamp.push(lum[sy * width + sx] - bg);
809            }
810        }
811        let rel_cx = star.x - x0 as f32;
812        let rel_cy = star.y - y0 as f32;
813        let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
814        let fwhm = sigma * 2.3548;
815        if fwhm > 1.0 && fwhm < 20.0 {
816            fwhm_vals.push(fwhm);
817        }
818    }
819
820    if fwhm_vals.len() < 3 {
821        return 0.0;
822    }
823    find_median(&mut fwhm_vals)
824}
825
826/// Build a boolean mask marking green CFA pixel positions.
827///
828/// Returns a `Vec<bool>` of length `width * height` where `true` marks pixels
829/// that are at green positions in the Bayer pattern. For GBRG/GRBG green is
830/// at (row + col) even; for RGGB/BGGR green is at (row + col) odd.
831fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
832    let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
833    (0..height)
834        .flat_map(|y| {
835            (0..width).map(move |x| {
836                let parity = (x + y) & 1;
837                if green_at_even { parity == 0 } else { parity == 1 }
838            })
839        })
840        .collect()
841}
842
843/// Extract luminance from planar RGB data: L = 0.2126R + 0.7152G + 0.0722B
844pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
845    use rayon::prelude::*;
846
847    let plane_size = width * height;
848    let r = &data[..plane_size];
849    let g = &data[plane_size..2 * plane_size];
850    let b = &data[2 * plane_size..3 * plane_size];
851
852    let mut lum = vec![0.0_f32; plane_size];
853    const CHUNK: usize = 8192;
854    lum.par_chunks_mut(CHUNK)
855        .enumerate()
856        .for_each(|(ci, chunk)| {
857            let off = ci * CHUNK;
858            for (i, dst) in chunk.iter_mut().enumerate() {
859                let idx = off + i;
860                *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
861            }
862        });
863    lum
864}
865
866/// Prepare luminance data from raw metadata + pixels.
867///
868/// Handles u16→f32 conversion, green-channel interpolation for OSC,
869/// and luminance extraction for multi-channel images.
870/// Returns `(luminance, width, height, channels, green_mask)`.
871#[cfg(feature = "debug-pipeline")]
872pub fn prepare_luminance(
873    meta: &crate::types::ImageMetadata,
874    pixels: &crate::types::PixelData,
875    apply_debayer: bool,
876) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
877    use crate::processing::color::u16_to_f32;
878    use crate::processing::debayer;
879
880    let f32_data = match pixels {
881        PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
882        PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
883    };
884
885    let mut data = f32_data.into_owned();
886    let width = meta.width;
887    let height = meta.height;
888    let channels = meta.channels;
889
890    let green_mask = if apply_debayer
891        && meta.bayer_pattern != BayerPattern::None
892        && channels == 1
893    {
894        data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
895        Some(build_green_mask(width, height, meta.bayer_pattern))
896    } else {
897        None
898    };
899
900    let lum = if channels == 3 {
901        extract_luminance(&data, width, height)
902    } else {
903        data[..width * height].to_vec()
904    };
905
906    (lum, width, height, channels, green_mask)
907}