Skip to main content

astroimage/analysis/
mod.rs

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