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}
93
94/// Full analysis result for an image.
95pub struct AnalysisResult {
96    /// Image width (after debayer if applicable).
97    pub width: usize,
98    /// Image height (after debayer if applicable).
99    pub height: usize,
100    /// Number of source channels: 1 = mono, 3 = color (after debayer).
101    pub source_channels: usize,
102    /// Global background level (ADU).
103    pub background: f32,
104    /// Background noise sigma (ADU).
105    pub noise: f32,
106    /// Actual detection threshold used (ADU above background).
107    pub detection_threshold: f32,
108    /// Total stars with valid PSF measurements (statistics computed from all).
109    pub stars_detected: usize,
110    /// Per-star metrics, sorted by flux descending, capped at max_stars.
111    pub stars: Vec<StarMetrics>,
112    /// Median FWHM across all measured stars (pixels).
113    pub median_fwhm: f32,
114    /// Median eccentricity across all measured stars.
115    pub median_eccentricity: f32,
116    /// Median per-star SNR.
117    pub median_snr: f32,
118    /// Median half-flux radius (pixels).
119    pub median_hfr: f32,
120    /// SNR weight for frame ranking: (MeanDev / noise)².
121    pub snr_weight: f32,
122    /// PSF signal: median(star_peaks) / noise.
123    pub psf_signal: f32,
124    /// Per-frame SNR: background / noise (linear ratio).
125    /// Use for stacking prediction: stacked_snr = sqrt(sum(frame_snr_i²)).
126    pub frame_snr: f32,
127    /// Rayleigh R² statistic for directional coherence of star position angles.
128    /// 0.0 = uniform (no trail), 1.0 = all stars aligned (strong trail).
129    /// Computed from detection-stage stamp moments on 2θ.
130    pub trail_r_squared: f32,
131    /// True if the image is likely trailed, based on the Rayleigh test.
132    /// Uses configurable R² threshold (default 0.5) and eccentricity gate.
133    pub possibly_trailed: bool,
134    /// Measured FWHM from adaptive two-pass detection (pixels).
135    /// This is the FWHM used for the final matched filter kernel.
136    /// If the first-pass FWHM was within 30% of 3.0, this equals 3.0.
137    pub measured_fwhm_kernel: f32,
138    /// Median Moffat β across all stars (None if Moffat fitting not used).
139    /// Typical range: 2.0-5.0 for real optics. Lower = broader wings.
140    pub median_beta: Option<f32>,
141}
142
143/// Builder configuration for analysis (internal).
144pub struct AnalysisConfig {
145    detection_sigma: f32,
146    min_star_area: usize,
147    max_star_area: usize,
148    saturation_fraction: f32,
149    max_stars: usize,
150    apply_debayer: bool,
151    trail_r_squared_threshold: f32,
152    /// MRS wavelet noise layers (default 1).
153    noise_layers: usize,
154}
155
156/// Image analyzer with builder pattern.
157pub struct ImageAnalyzer {
158    config: AnalysisConfig,
159    thread_pool: Option<Arc<rayon::ThreadPool>>,
160}
161
162impl ImageAnalyzer {
163    pub fn new() -> Self {
164        ImageAnalyzer {
165            config: AnalysisConfig {
166                detection_sigma: 5.0,
167                min_star_area: 5,
168                max_star_area: 2000,
169                saturation_fraction: 0.95,
170                max_stars: 200,
171                apply_debayer: true,
172                trail_r_squared_threshold: 0.5,
173                noise_layers: 1,
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    /// Keep only the brightest N stars in the returned result.
204    pub fn with_max_stars(mut self, n: usize) -> Self {
205        self.config.max_stars = n.max(1);
206        self
207    }
208
209    /// Skip debayering for OSC images (less accurate but faster).
210    pub fn without_debayer(mut self) -> Self {
211        self.config.apply_debayer = false;
212        self
213    }
214
215    /// Set the R² threshold for trail detection.
216    /// Images with Rayleigh R² above this are flagged as possibly trailed.
217    /// Default: 0.5. Lower values are more aggressive (more false positives).
218    pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
219        self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
220        self
221    }
222
223    /// Set MRS wavelet noise layers.
224    /// Uses à trous B3-spline wavelet to isolate noise from nebulosity/gradients.
225    /// Default: 1.
226    pub fn with_mrs_layers(mut self, layers: usize) -> Self {
227        self.config.noise_layers = layers.max(1);
228        self
229    }
230
231    /// Use a custom rayon thread pool.
232    pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
233        self.thread_pool = Some(pool);
234        self
235    }
236
237    /// Analyze a FITS or XISF image file.
238    pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
239        let path = path.as_ref();
240        match &self.thread_pool {
241            Some(pool) => pool.install(|| self.analyze_impl(path)),
242            None => self.analyze_impl(path),
243        }
244    }
245
246    /// Analyze pre-loaded f32 pixel data.
247    ///
248    /// `data`: planar f32 pixel data (for 3-channel: RRRGGGBBB layout).
249    /// `width`: image width.
250    /// `height`: image height.
251    /// `channels`: 1 for mono, 3 for RGB.
252    pub fn analyze_data(
253        &self,
254        data: &[f32],
255        width: usize,
256        height: usize,
257        channels: usize,
258    ) -> Result<AnalysisResult> {
259        match &self.thread_pool {
260            Some(pool) => pool.install(|| {
261                self.run_analysis(data, width, height, channels, None)
262            }),
263            None => self.run_analysis(data, width, height, channels, None),
264        }
265    }
266
267    /// Analyze pre-read raw pixel data (skips file I/O).
268    ///
269    /// Accepts `ImageMetadata` and borrows `PixelData`, handling u16→f32
270    /// conversion and green-channel interpolation for OSC images internally.
271    pub fn analyze_raw(
272        &self,
273        meta: &ImageMetadata,
274        pixels: &PixelData,
275    ) -> Result<AnalysisResult> {
276        match &self.thread_pool {
277            Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
278            None => self.analyze_raw_impl(meta, pixels),
279        }
280    }
281
282    fn analyze_raw_impl(
283        &self,
284        meta: &ImageMetadata,
285        pixels: &PixelData,
286    ) -> Result<AnalysisResult> {
287        let f32_data = match pixels {
288            PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
289            PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
290        };
291
292        let mut data = f32_data.into_owned();
293        let width = meta.width;
294        let height = meta.height;
295        let channels = meta.channels;
296
297        let green_mask = if self.config.apply_debayer
298            && meta.bayer_pattern != BayerPattern::None
299            && channels == 1
300        {
301            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
302            Some(build_green_mask(width, height, meta.bayer_pattern))
303        } else {
304            None
305        };
306
307        self.run_analysis(&data, width, height, channels, green_mask.as_deref())
308    }
309
310    fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
311        let (meta, pixel_data) =
312            formats::read_image(path).context("Failed to read image for analysis")?;
313
314        // Convert to f32
315        let f32_data = match pixel_data {
316            PixelData::Float32(d) => d,
317            PixelData::Uint16(d) => u16_to_f32(&d),
318        };
319
320        let mut data = f32_data;
321        let width = meta.width;
322        let height = meta.height;
323        let channels = meta.channels;
324
325        // Green-channel interpolation for OSC: native-resolution mono green image
326        // (matches Siril's interpolate_nongreen — no PSF broadening from 2×2 binning)
327        let green_mask = if self.config.apply_debayer
328            && meta.bayer_pattern != BayerPattern::None
329            && channels == 1
330        {
331            data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
332            // width, height, channels unchanged — native resolution mono green
333            Some(build_green_mask(width, height, meta.bayer_pattern))
334        } else {
335            None
336        };
337
338        self.run_analysis(&data, width, height, channels, green_mask.as_deref())
339    }
340
341    fn run_analysis(
342        &self,
343        data: &[f32],
344        width: usize,
345        height: usize,
346        channels: usize,
347        green_mask: Option<&[bool]>,
348    ) -> Result<AnalysisResult> {
349        // Extract luminance if multi-channel
350        let lum = if channels == 3 {
351            extract_luminance(data, width, height)
352        } else {
353            data[..width * height].to_vec()
354        };
355
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        // ── Stage 1: Background & Noise ──────────────────────────────────
364        let cell_size = background::auto_cell_size(width, height);
365        let mut bg_result = background::estimate_background_mesh(&lum, width, height, cell_size);
366        bg_result.noise = background::estimate_noise_mrs(
367            &lum, width, height, self.config.noise_layers.max(1),
368        ).max(0.001);
369
370        // ── Stage 2, Pass 1: Discovery ───────────────────────────────────
371        let initial_fwhm = 3.0_f32;
372        let pass1_stars = {
373            let bg_map = bg_result.background_map.as_deref();
374            let noise_map = bg_result.noise_map.as_deref();
375            detection::detect_stars(
376                &lum, width, height,
377                bg_result.background, bg_result.noise,
378                bg_map, noise_map, &det_params, initial_fwhm,
379            )
380        };
381
382        // Select calibration stars: brightest, not saturated, not too elongated
383        let calibration_stars: Vec<&detection::DetectedStar> = pass1_stars
384            .iter()
385            .filter(|s| s.eccentricity < 0.5 && s.area >= 5)
386            .take(100)
387            .collect();
388
389        // Free-beta Moffat on calibration stars to discover field PSF model
390        let field_beta: Option<f64>;
391        let field_fwhm: f32;
392        if calibration_stars.len() >= 3 {
393            let cal_owned: Vec<detection::DetectedStar> = calibration_stars
394                .iter()
395                .map(|s| detection::DetectedStar {
396                    x: s.x, y: s.y, peak: s.peak, flux: s.flux,
397                    area: s.area, theta: s.theta, eccentricity: s.eccentricity,
398                })
399                .collect();
400            let cal_measured = metrics::measure_stars(
401                &lum, width, height, &cal_owned,
402                bg_result.background,
403                bg_result.background_map.as_deref(),
404                green_mask,
405                None, // free-beta Moffat
406            );
407
408            let mut beta_vals: Vec<f32> = cal_measured.iter().filter_map(|s| s.beta).collect();
409            let mut fwhm_vals: Vec<f32> = cal_measured.iter().map(|s| s.fwhm).collect();
410
411            if beta_vals.len() >= 3 {
412                field_beta = Some(sigma_clipped_median(&beta_vals) as f64);
413            } else if !beta_vals.is_empty() {
414                field_beta = Some(find_median(&mut beta_vals) as f64);
415            } else {
416                field_beta = None;
417            }
418
419            if fwhm_vals.len() >= 3 {
420                field_fwhm = sigma_clipped_median(&fwhm_vals);
421            } else if !fwhm_vals.is_empty() {
422                field_fwhm = find_median(&mut fwhm_vals);
423            } else {
424                field_fwhm = estimate_fwhm_from_stars(
425                    &lum, width, height, &pass1_stars,
426                    bg_result.background, bg_result.background_map.as_deref(),
427                );
428            }
429        } else {
430            // Too few calibration stars — fall back to halfmax estimate
431            field_beta = None;
432            field_fwhm = estimate_fwhm_from_stars(
433                &lum, width, height, &pass1_stars,
434                bg_result.background, bg_result.background_map.as_deref(),
435            );
436        }
437
438        // Source-mask background re-estimation
439        let mask_fwhm = if field_fwhm > 1.0 { field_fwhm } else { initial_fwhm };
440        if !pass1_stars.is_empty() {
441            let mask_radius = (2.5 * mask_fwhm).ceil() as i32;
442            let mask_r_sq = (mask_radius * mask_radius) as f32;
443            let mut source_mask = vec![false; width * height];
444            for star in &pass1_stars {
445                let cx = star.x.round() as i32;
446                let cy = star.y.round() as i32;
447                for dy in -mask_radius..=mask_radius {
448                    let py = cy + dy;
449                    if py < 0 || py >= height as i32 { continue; }
450                    for dx in -mask_radius..=mask_radius {
451                        let px = cx + dx;
452                        if px < 0 || px >= width as i32 { continue; }
453                        if (dx * dx + dy * dy) as f32 <= mask_r_sq {
454                            source_mask[py as usize * width + px as usize] = true;
455                        }
456                    }
457                }
458            }
459            bg_result = background::estimate_background_mesh_masked(
460                &lum, width, height, cell_size, &source_mask,
461            );
462            bg_result.noise = background::estimate_noise_mrs(
463                &lum, width, height, self.config.noise_layers.max(1),
464            ).max(0.001);
465        }
466
467        // ── Stage 2, Pass 2: Full detection with refined kernel ──────────
468        let final_fwhm = if field_fwhm > 1.0
469            && ((field_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
470        {
471            field_fwhm.min(initial_fwhm * 2.0)
472        } else {
473            initial_fwhm
474        };
475
476        let detected = {
477            let bg_map = bg_result.background_map.as_deref();
478            let noise_map = bg_result.noise_map.as_deref();
479            detection::detect_stars(
480                &lum, width, height,
481                bg_result.background, bg_result.noise,
482                bg_map, noise_map, &det_params, final_fwhm,
483            )
484        };
485
486        let bg_map_ref = bg_result.background_map.as_deref();
487        let detection_threshold = self.config.detection_sigma * bg_result.noise;
488
489        // ── Trail detection (Rayleigh test on detection-stage moments) ────
490        let (trail_r_squared, possibly_trailed) = if detected.len() >= 5 {
491            let n = detected.len();
492            let (sum_cos, sum_sin) =
493                detected.iter().fold((0.0f64, 0.0f64), |(sc, ss), s| {
494                    let a = 2.0 * s.theta as f64;
495                    (sc + a.cos(), ss + a.sin())
496                });
497            let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
498            let p = (-(n as f64) * r_sq).exp();
499            let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
500            eccs.sort_unstable_by(|a, b| a.total_cmp(b));
501            let median_ecc = eccs[eccs.len() / 2];
502            let threshold = self.config.trail_r_squared_threshold as f64;
503            let trailed = (r_sq > threshold && p < 0.01)
504                || (median_ecc > 0.6 && p < 0.05);
505            (r_sq as f32, trailed)
506        } else {
507            (0.0, false)
508        };
509
510        let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
511        let frame_snr = if bg_result.noise > 0.0 { bg_result.background / bg_result.noise } else { 0.0 };
512
513        let make_zero_result = |stars_detected: usize| {
514            Ok(AnalysisResult {
515                width, height, source_channels: channels,
516                background: bg_result.background, noise: bg_result.noise,
517                detection_threshold, stars_detected,
518                stars: Vec::new(),
519                median_fwhm: 0.0, median_eccentricity: 0.0,
520                median_snr: 0.0, median_hfr: 0.0,
521                snr_weight, psf_signal: 0.0, frame_snr,
522                trail_r_squared, possibly_trailed,
523                measured_fwhm_kernel: final_fwhm,
524                median_beta: field_beta.map(|b| b as f32),
525            })
526        };
527
528        if detected.is_empty() {
529            return make_zero_result(0);
530        }
531
532        // ── Stage 3: Fixed-beta Moffat on all stars ──────────────────────
533        let mut measured = metrics::measure_stars(
534            &lum, width, height, &detected,
535            bg_result.background, bg_map_ref,
536            green_mask, field_beta,
537        );
538
539        if measured.is_empty() {
540            return make_zero_result(0);
541        }
542
543        // ── Stage 4: Metrics ─────────────────────────────────────────────
544        let stars_detected = measured.len();
545
546        let fwhm_vals: Vec<f32> = measured.iter().map(|s| s.fwhm).collect();
547        let median_fwhm = sigma_clipped_median(&fwhm_vals);
548
549        snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
550
551        let ecc_vals: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
552        let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
553        let hfr_vals: Vec<f32> = measured.iter().map(|s| s.hfr).collect();
554
555        let median_eccentricity = sigma_clipped_median(&ecc_vals);
556        let median_snr = find_median(&mut snr_vals);
557        let median_hfr = sigma_clipped_median(&hfr_vals);
558        let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
559
560        // Median beta: use field_beta from calibration, or compute from all stars
561        let median_beta = if let Some(fb) = field_beta {
562            Some(fb as f32)
563        } else {
564            let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
565            if beta_vals.is_empty() { None } else { Some(find_median(&mut beta_vals)) }
566        };
567
568        // Late cap: truncate to max_stars AFTER all statistics are computed
569        measured.truncate(self.config.max_stars);
570
571        let stars: Vec<StarMetrics> = measured
572            .into_iter()
573            .map(|m| StarMetrics {
574                x: m.x, y: m.y, peak: m.peak, flux: m.flux,
575                fwhm_x: m.fwhm_x, fwhm_y: m.fwhm_y, fwhm: m.fwhm,
576                eccentricity: m.eccentricity, snr: m.snr, hfr: m.hfr,
577                theta: m.theta, beta: m.beta, fit_method: m.fit_method,
578            })
579            .collect();
580
581        Ok(AnalysisResult {
582            width, height, source_channels: channels,
583            background: bg_result.background, noise: bg_result.noise,
584            detection_threshold, stars_detected, stars,
585            median_fwhm, median_eccentricity, median_snr, median_hfr,
586            snr_weight, psf_signal, frame_snr,
587            trail_r_squared, possibly_trailed,
588            measured_fwhm_kernel: final_fwhm,
589            median_beta,
590        })
591    }
592}
593
594impl Default for ImageAnalyzer {
595    fn default() -> Self {
596        Self::new()
597    }
598}
599
600/// Sigma-clipped median: 2-iteration, 3σ MAD-based clipping.
601///
602/// Standard in SExtractor/DAOPHOT for robust statistics:
603///   MAD = median(|x_i − median|)
604///   σ_MAD = 1.4826 × MAD
605///   reject: |x − median| > 3 × σ_MAD
606///
607/// Returns plain median if fewer than 3 values remain after clipping.
608pub fn sigma_clipped_median(values: &[f32]) -> f32 {
609    if values.is_empty() {
610        return 0.0;
611    }
612    let mut v: Vec<f32> = values.to_vec();
613    for _ in 0..2 {
614        if v.len() < 3 {
615            break;
616        }
617        let med = find_median(&mut v);
618        let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
619        let mad = find_median(&mut abs_devs);
620        let sigma_mad = 1.4826 * mad;
621        if sigma_mad < 1e-10 {
622            break; // all values identical
623        }
624        let clip = 3.0 * sigma_mad;
625        v.retain(|&x| (x - med).abs() <= clip);
626    }
627    if v.is_empty() {
628        return find_median(&mut values.to_vec());
629    }
630    find_median(&mut v)
631}
632
633/// Estimate FWHM from the brightest detected stars by extracting stamps
634/// and using `estimate_sigma_halfmax`. Returns median FWHM, or 0.0 if
635/// fewer than 3 stars yield valid measurements.
636pub fn estimate_fwhm_from_stars(
637    lum: &[f32],
638    width: usize,
639    height: usize,
640    stars: &[detection::DetectedStar],
641    background: f32,
642    bg_map: Option<&[f32]>,
643) -> f32 {
644    // Scan top 50 brightest (already sorted by flux descending), select up to 20
645    // with low eccentricity (≤ 0.7) to avoid elongated non-stellar objects
646    // poisoning the kernel estimate.
647    let scan_n = stars.len().min(50);
648    if scan_n < 3 {
649        return 0.0;
650    }
651
652    let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
653        .iter()
654        .filter(|s| s.eccentricity <= 0.7)
655        .take(20)
656        .collect();
657    if round_stars.len() < 3 {
658        return 0.0;
659    }
660
661    let mut fwhm_vals = Vec::with_capacity(round_stars.len());
662    for star in &round_stars {
663        let stamp_radius = 12_usize; // enough for FWHM up to ~10px
664        let cx = star.x.round() as i32;
665        let cy = star.y.round() as i32;
666        let sr = stamp_radius as i32;
667        if cx - sr <= 0 || cy - sr <= 0
668            || cx + sr >= width as i32 - 1
669            || cy + sr >= height as i32 - 1
670        {
671            continue;
672        }
673        let x0 = (cx - sr) as usize;
674        let y0 = (cy - sr) as usize;
675        let x1 = (cx + sr) as usize;
676        let y1 = (cy + sr) as usize;
677        let stamp_w = x1 - x0 + 1;
678        let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
679        for sy in y0..=y1 {
680            for sx in x0..=x1 {
681                let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
682                stamp.push(lum[sy * width + sx] - bg);
683            }
684        }
685        let rel_cx = star.x - x0 as f32;
686        let rel_cy = star.y - y0 as f32;
687        let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
688        let fwhm = sigma * 2.3548;
689        if fwhm > 1.0 && fwhm < 20.0 {
690            fwhm_vals.push(fwhm);
691        }
692    }
693
694    if fwhm_vals.len() < 3 {
695        return 0.0;
696    }
697    find_median(&mut fwhm_vals)
698}
699
700/// Build a boolean mask marking green CFA pixel positions.
701///
702/// Returns a `Vec<bool>` of length `width * height` where `true` marks pixels
703/// that are at green positions in the Bayer pattern. For GBRG/GRBG green is
704/// at (row + col) even; for RGGB/BGGR green is at (row + col) odd.
705fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
706    let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
707    (0..height)
708        .flat_map(|y| {
709            (0..width).map(move |x| {
710                let parity = (x + y) & 1;
711                if green_at_even { parity == 0 } else { parity == 1 }
712            })
713        })
714        .collect()
715}
716
717/// Extract luminance from planar RGB data: L = 0.2126R + 0.7152G + 0.0722B
718pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
719    use rayon::prelude::*;
720
721    let plane_size = width * height;
722    let r = &data[..plane_size];
723    let g = &data[plane_size..2 * plane_size];
724    let b = &data[2 * plane_size..3 * plane_size];
725
726    let mut lum = vec![0.0_f32; plane_size];
727    const CHUNK: usize = 8192;
728    lum.par_chunks_mut(CHUNK)
729        .enumerate()
730        .for_each(|(ci, chunk)| {
731            let off = ci * CHUNK;
732            for (i, dst) in chunk.iter_mut().enumerate() {
733                let idx = off + i;
734                *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
735            }
736        });
737    lum
738}
739
740/// Prepare luminance data from raw metadata + pixels.
741///
742/// Handles u16→f32 conversion, green-channel interpolation for OSC,
743/// and luminance extraction for multi-channel images.
744/// Returns `(luminance, width, height, channels, green_mask)`.
745#[cfg(feature = "debug-pipeline")]
746pub fn prepare_luminance(
747    meta: &crate::types::ImageMetadata,
748    pixels: &crate::types::PixelData,
749    apply_debayer: bool,
750) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
751    use crate::processing::color::u16_to_f32;
752    use crate::processing::debayer;
753
754    let f32_data = match pixels {
755        PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
756        PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
757    };
758
759    let mut data = f32_data.into_owned();
760    let width = meta.width;
761    let height = meta.height;
762    let channels = meta.channels;
763
764    let green_mask = if apply_debayer
765        && meta.bayer_pattern != BayerPattern::None
766        && channels == 1
767    {
768        data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
769        Some(build_green_mask(width, height, meta.bayer_pattern))
770    } else {
771        None
772    };
773
774    let lum = if channels == 3 {
775        extract_luminance(&data, width, height)
776    } else {
777        data[..width * height].to_vec()
778    };
779
780    (lum, width, height, channels, green_mask)
781}