1#[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#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum FitMethod {
52 FreeMoffat,
54 FixedMoffat,
56 Gaussian,
58 Moments,
60}
61
62pub struct StarMetrics {
64 pub x: f32,
66 pub y: f32,
68 pub peak: f32,
70 pub flux: f32,
72 pub fwhm_x: f32,
74 pub fwhm_y: f32,
76 pub fwhm: f32,
78 pub eccentricity: f32,
80 pub snr: f32,
82 pub hfr: f32,
84 pub theta: f32,
88 pub beta: Option<f32>,
90 pub fit_method: FitMethod,
92 pub fit_residual: f32,
95}
96
97pub struct AnalysisResult {
99 pub width: usize,
101 pub height: usize,
103 pub source_channels: usize,
105 pub background: f32,
107 pub noise: f32,
109 pub detection_threshold: f32,
111 pub stars_detected: usize,
113 pub stars: Vec<StarMetrics>,
115 pub median_fwhm: f32,
117 pub median_eccentricity: f32,
119 pub median_snr: f32,
121 pub median_hfr: f32,
123 pub snr_weight: f32,
125 pub psf_signal: f32,
127 pub frame_snr: f32,
130 pub trail_r_squared: f32,
135 pub possibly_trailed: bool,
140 pub measured_fwhm_kernel: f32,
144 pub median_beta: Option<f32>,
147}
148
149pub struct AnalysisConfig {
151 detection_sigma: f32,
152 min_star_area: usize,
153 max_star_area: usize,
154 saturation_fraction: f32,
155 max_stars: usize,
156 apply_debayer: bool,
157 trail_r_squared_threshold: f32,
158 noise_layers: usize,
160 measure_cap: usize,
162 fit_max_iter: usize,
164 fit_tolerance: f64,
166 fit_max_rejects: usize,
168}
169
170pub struct ImageAnalyzer {
172 config: AnalysisConfig,
173 thread_pool: Option<Arc<rayon::ThreadPool>>,
174}
175
176impl ImageAnalyzer {
177 pub fn new() -> Self {
178 ImageAnalyzer {
179 config: AnalysisConfig {
180 detection_sigma: 5.0,
181 min_star_area: 5,
182 max_star_area: 2000,
183 saturation_fraction: 0.95,
184 max_stars: 200,
185 apply_debayer: true,
186 trail_r_squared_threshold: 0.5,
187 noise_layers: 4,
188 measure_cap: 500,
189 fit_max_iter: 25,
190 fit_tolerance: 1e-4,
191 fit_max_rejects: 5,
192 },
193 thread_pool: None,
194 }
195 }
196
197 pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
199 self.config.detection_sigma = sigma.max(1.0);
200 self
201 }
202
203 pub fn with_min_star_area(mut self, area: usize) -> Self {
205 self.config.min_star_area = area.max(1);
206 self
207 }
208
209 pub fn with_max_star_area(mut self, area: usize) -> Self {
211 self.config.max_star_area = area;
212 self
213 }
214
215 pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
217 self.config.saturation_fraction = frac.clamp(0.5, 1.0);
218 self
219 }
220
221 pub fn with_max_stars(mut self, n: usize) -> Self {
223 self.config.max_stars = n.max(1);
224 self
225 }
226
227 pub fn without_debayer(mut self) -> Self {
229 self.config.apply_debayer = false;
230 self
231 }
232
233 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
237 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
238 self
239 }
240
241 pub fn with_mrs_layers(mut self, layers: usize) -> Self {
245 self.config.noise_layers = layers.max(1);
246 self
247 }
248
249 pub fn with_measure_cap(mut self, n: usize) -> Self {
253 self.config.measure_cap = n;
254 self
255 }
256
257 pub fn with_fit_max_iter(mut self, n: usize) -> Self {
260 self.config.fit_max_iter = n.max(1);
261 self
262 }
263
264 pub fn with_fit_tolerance(mut self, tol: f64) -> Self {
267 self.config.fit_tolerance = tol;
268 self
269 }
270
271 pub fn with_fit_max_rejects(mut self, n: usize) -> Self {
273 self.config.fit_max_rejects = n.max(1);
274 self
275 }
276
277 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
279 self.thread_pool = Some(pool);
280 self
281 }
282
283 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
285 let path = path.as_ref();
286 match &self.thread_pool {
287 Some(pool) => pool.install(|| self.analyze_impl(path)),
288 None => self.analyze_impl(path),
289 }
290 }
291
292 pub fn analyze_data(
299 &self,
300 data: &[f32],
301 width: usize,
302 height: usize,
303 channels: usize,
304 ) -> Result<AnalysisResult> {
305 match &self.thread_pool {
306 Some(pool) => pool.install(|| {
307 self.run_analysis(data, width, height, channels, None)
308 }),
309 None => self.run_analysis(data, width, height, channels, None),
310 }
311 }
312
313 pub fn analyze_raw(
318 &self,
319 meta: &ImageMetadata,
320 pixels: &PixelData,
321 ) -> Result<AnalysisResult> {
322 match &self.thread_pool {
323 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
324 None => self.analyze_raw_impl(meta, pixels),
325 }
326 }
327
328 fn analyze_raw_impl(
329 &self,
330 meta: &ImageMetadata,
331 pixels: &PixelData,
332 ) -> Result<AnalysisResult> {
333 let f32_data = match pixels {
334 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
335 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
336 };
337
338 let mut data = f32_data.into_owned();
339 let width = meta.width;
340 let height = meta.height;
341 let channels = meta.channels;
342
343 let green_mask = if self.config.apply_debayer
344 && meta.bayer_pattern != BayerPattern::None
345 && channels == 1
346 {
347 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
348 Some(build_green_mask(width, height, meta.bayer_pattern))
349 } else {
350 None
351 };
352
353 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
354 }
355
356 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
357 let (meta, pixel_data) =
358 formats::read_image(path).context("Failed to read image for analysis")?;
359
360 let f32_data = match pixel_data {
362 PixelData::Float32(d) => d,
363 PixelData::Uint16(d) => u16_to_f32(&d),
364 };
365
366 let mut data = f32_data;
367 let width = meta.width;
368 let height = meta.height;
369 let channels = meta.channels;
370
371 let green_mask = if self.config.apply_debayer
374 && meta.bayer_pattern != BayerPattern::None
375 && channels == 1
376 {
377 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
378 Some(build_green_mask(width, height, meta.bayer_pattern))
380 } else {
381 None
382 };
383
384 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
385 }
386
387 fn run_analysis(
388 &self,
389 data: &[f32],
390 width: usize,
391 height: usize,
392 channels: usize,
393 green_mask: Option<&[bool]>,
394 ) -> Result<AnalysisResult> {
395 let lum = if channels == 3 {
397 extract_luminance(data, width, height)
398 } else {
399 data[..width * height].to_vec()
400 };
401
402 let det_params = DetectionParams {
403 detection_sigma: self.config.detection_sigma,
404 min_star_area: self.config.min_star_area,
405 max_star_area: self.config.max_star_area,
406 saturation_limit: self.config.saturation_fraction * 65535.0,
407 };
408
409 let cell_size = background::auto_cell_size(width, height);
411 let mut bg_result = background::estimate_background_mesh(&lum, width, height, cell_size);
412 bg_result.noise = background::estimate_noise_mrs(
413 &lum, width, height, self.config.noise_layers.max(1),
414 ).max(0.001);
415
416 let initial_fwhm = 3.0_f32;
418 let pass1_stars = {
419 let bg_map = bg_result.background_map.as_deref();
420 let noise_map = bg_result.noise_map.as_deref();
421 detection::detect_stars(
422 &lum, width, height,
423 bg_result.background, bg_result.noise,
424 bg_map, noise_map, &det_params, initial_fwhm,
425 None,
426 )
427 };
428
429 let calibration_stars: Vec<&detection::DetectedStar> = pass1_stars
431 .iter()
432 .filter(|s| s.eccentricity < 0.5 && s.area >= 5)
433 .take(100)
434 .collect();
435
436 let field_beta: Option<f64>;
438 let field_fwhm: f32;
439 if calibration_stars.len() >= 3 {
440 let cal_owned: Vec<detection::DetectedStar> = calibration_stars
441 .iter()
442 .map(|s| detection::DetectedStar {
443 x: s.x, y: s.y, peak: s.peak, flux: s.flux,
444 area: s.area, theta: s.theta, eccentricity: s.eccentricity,
445 })
446 .collect();
447 let cal_measured = metrics::measure_stars(
448 &lum, width, height, &cal_owned,
449 bg_result.background,
450 bg_result.background_map.as_deref(),
451 green_mask,
452 None, 50, 1e-6, 5, None, false, );
457
458 let mut beta_vals: Vec<f32> = cal_measured.iter().filter_map(|s| s.beta).collect();
459 let mut fwhm_vals: Vec<f32> = cal_measured.iter().map(|s| s.fwhm).collect();
460
461 if beta_vals.len() >= 3 {
462 field_beta = Some(sigma_clipped_median(&beta_vals) as f64);
463 } else if !beta_vals.is_empty() {
464 field_beta = Some(find_median(&mut beta_vals) as f64);
465 } else {
466 field_beta = None;
467 }
468
469 if fwhm_vals.len() >= 3 {
470 field_fwhm = sigma_clipped_median(&fwhm_vals);
471 } else if !fwhm_vals.is_empty() {
472 field_fwhm = find_median(&mut fwhm_vals);
473 } else {
474 field_fwhm = estimate_fwhm_from_stars(
475 &lum, width, height, &pass1_stars,
476 bg_result.background, bg_result.background_map.as_deref(),
477 );
478 }
479 } else {
480 field_beta = None;
482 field_fwhm = estimate_fwhm_from_stars(
483 &lum, width, height, &pass1_stars,
484 bg_result.background, bg_result.background_map.as_deref(),
485 );
486 }
487
488 let mask_fwhm = if field_fwhm > 1.0 { field_fwhm } else { initial_fwhm };
490 if !pass1_stars.is_empty() {
491 let mask_radius = (2.5 * mask_fwhm).ceil() as i32;
492 let mask_r_sq = (mask_radius * mask_radius) as f32;
493 let mut source_mask = vec![false; width * height];
494 for star in &pass1_stars {
495 let cx = star.x.round() as i32;
496 let cy = star.y.round() as i32;
497 for dy in -mask_radius..=mask_radius {
498 let py = cy + dy;
499 if py < 0 || py >= height as i32 { continue; }
500 for dx in -mask_radius..=mask_radius {
501 let px = cx + dx;
502 if px < 0 || px >= width as i32 { continue; }
503 if (dx * dx + dy * dy) as f32 <= mask_r_sq {
504 source_mask[py as usize * width + px as usize] = true;
505 }
506 }
507 }
508 }
509 bg_result = background::estimate_background_mesh_masked(
510 &lum, width, height, cell_size, &source_mask,
511 );
512 bg_result.noise = background::estimate_noise_mrs(
513 &lum, width, height, self.config.noise_layers.max(1),
514 ).max(0.001);
515 }
516
517 let final_fwhm = if field_fwhm > 1.0
519 && ((field_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
520 {
521 field_fwhm.min(initial_fwhm * 2.0)
522 } else {
523 initial_fwhm
524 };
525
526 let detected = {
527 let bg_map = bg_result.background_map.as_deref();
528 let noise_map = bg_result.noise_map.as_deref();
529 detection::detect_stars(
530 &lum, width, height,
531 bg_result.background, bg_result.noise,
532 bg_map, noise_map, &det_params, final_fwhm,
533 Some(field_fwhm),
534 )
535 };
536
537 let bg_map_ref = bg_result.background_map.as_deref();
538 let detection_threshold = self.config.detection_sigma * bg_result.noise;
539
540 let (trail_r_squared, possibly_trailed) = if detected.len() >= 20 {
544 let n = detected.len();
545 let (sum_cos, sum_sin) =
546 detected.iter().fold((0.0f64, 0.0f64), |(sc, ss), s| {
547 let a = 2.0 * s.theta as f64;
548 (sc + a.cos(), ss + a.sin())
549 });
550 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
551 let p = (-(n as f64) * r_sq).exp();
552 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
553 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
554 let median_ecc = if eccs.len() % 2 == 1 {
555 eccs[eccs.len() / 2]
556 } else {
557 (eccs[eccs.len() / 2 - 1] + eccs[eccs.len() / 2]) * 0.5
558 };
559 let threshold = self.config.trail_r_squared_threshold as f64;
560 let trailed = (r_sq > threshold && p < 0.01) || (r_sq > 0.05 && median_ecc > 0.6 && p < 0.05); (r_sq as f32, trailed)
563 } else {
564 (0.0, false)
565 };
566
567 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
568 let frame_snr = if bg_result.noise > 0.0 { bg_result.background / bg_result.noise } else { 0.0 };
569
570 let make_zero_result = |stars_detected: usize| {
571 Ok(AnalysisResult {
572 width, height, source_channels: channels,
573 background: bg_result.background, noise: bg_result.noise,
574 detection_threshold, stars_detected,
575 stars: Vec::new(),
576 median_fwhm: 0.0, median_eccentricity: 0.0,
577 median_snr: 0.0, median_hfr: 0.0,
578 snr_weight, psf_signal: 0.0, frame_snr,
579 trail_r_squared, possibly_trailed,
580 measured_fwhm_kernel: final_fwhm,
581 median_beta: field_beta.map(|b| b as f32),
582 })
583 };
584
585 if detected.is_empty() {
586 return make_zero_result(0);
587 }
588
589 let stars_detected = detected.len();
591
592 let effective_cap = if self.config.measure_cap == 0 {
596 detected.len()
597 } else {
598 self.config.measure_cap
599 };
600
601 let to_measure: Vec<detection::DetectedStar> = if detected.len() <= effective_cap {
602 detected.clone()
603 } else {
604 debug_assert!(
605 detected.windows(2).all(|w| w[0].flux >= w[1].flux),
606 "detected stars must be sorted by flux descending"
607 );
608 const GRID_N: usize = 4;
609 let cell_w = width as f32 / GRID_N as f32;
610 let cell_h = height as f32 / GRID_N as f32;
611 let mut buckets: Vec<Vec<&detection::DetectedStar>> =
612 vec![Vec::new(); GRID_N * GRID_N];
613
614 for star in &detected {
615 let gx = ((star.x / cell_w) as usize).min(GRID_N - 1);
616 let gy = ((star.y / cell_h) as usize).min(GRID_N - 1);
617 buckets[gy * GRID_N + gx].push(star);
618 }
619
620 let mut selected: Vec<detection::DetectedStar> = Vec::with_capacity(effective_cap);
621 let mut idx = vec![0usize; GRID_N * GRID_N];
622 loop {
623 let mut added_any = false;
624 for cell in 0..(GRID_N * GRID_N) {
625 if selected.len() >= effective_cap { break; }
626 if idx[cell] < buckets[cell].len() {
627 selected.push(buckets[cell][idx[cell]].clone());
628 idx[cell] += 1;
629 added_any = true;
630 }
631 }
632 if !added_any || selected.len() >= effective_cap { break; }
633 }
634 selected
635 };
636
637 let mut measured = metrics::measure_stars(
638 &lum, width, height, &to_measure,
639 bg_result.background, bg_map_ref,
640 green_mask, field_beta,
641 self.config.fit_max_iter,
642 self.config.fit_tolerance,
643 self.config.fit_max_rejects,
644 Some(field_fwhm), possibly_trailed, );
647
648 if measured.is_empty() {
649 return make_zero_result(stars_detected);
650 }
651
652 let possibly_trailed = possibly_trailed || {
657 let mut fit_eccs: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
658 fit_eccs.sort_unstable_by(|a, b| a.total_cmp(b));
659 let med = if fit_eccs.len() % 2 == 1 {
660 fit_eccs[fit_eccs.len() / 2]
661 } else {
662 (fit_eccs[fit_eccs.len() / 2 - 1] + fit_eccs[fit_eccs.len() / 2]) * 0.5
663 };
664 med > 0.55
665 };
666
667 const FWHM_ECC_MAX: f32 = 0.8;
670 let fwhm_filtered: Vec<&metrics::MeasuredStar> = if possibly_trailed {
671 measured.iter().collect()
672 } else {
673 let round: Vec<&metrics::MeasuredStar> = measured.iter()
674 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
675 .collect();
676 if round.len() >= 3 { round } else { measured.iter().collect() }
677 };
678 let (fwhm_vals, hfr_vals, shape_weights) = (
679 fwhm_filtered.iter().map(|s| s.fwhm).collect::<Vec<f32>>(),
680 fwhm_filtered.iter().map(|s| s.hfr).collect::<Vec<f32>>(),
681 fwhm_filtered.iter().map(|s| 1.0 / (1.0 + s.fit_residual)).collect::<Vec<f32>>(),
682 );
683 let median_fwhm = sigma_clipped_weighted_median(&fwhm_vals, &shape_weights);
684
685 let ecc_use_all = possibly_trailed;
689 let ecc_filtered: Vec<&metrics::MeasuredStar> = if ecc_use_all {
690 measured.iter().collect()
691 } else {
692 let filtered: Vec<&metrics::MeasuredStar> = measured.iter()
693 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
694 .collect();
695 if filtered.len() >= 3 { filtered } else { measured.iter().collect() }
696 };
697 let ecc_vals: Vec<f32> = ecc_filtered.iter().map(|s| s.eccentricity).collect();
698 let ecc_weights: Vec<f32> = ecc_filtered.iter()
699 .map(|s| 1.0 / (1.0 + s.fit_residual))
700 .collect();
701
702 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
703
704 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
705
706 let median_eccentricity = sigma_clipped_weighted_median(&ecc_vals, &ecc_weights);
707 let median_snr = find_median(&mut snr_vals);
708 let median_hfr = sigma_clipped_weighted_median(&hfr_vals, &shape_weights);
709 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
710
711 let median_beta = if let Some(fb) = field_beta {
713 Some(fb as f32)
714 } else {
715 let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
716 if beta_vals.is_empty() { None } else { Some(find_median(&mut beta_vals)) }
717 };
718
719 measured.truncate(self.config.max_stars);
721
722 let stars: Vec<StarMetrics> = measured
723 .into_iter()
724 .map(|m| StarMetrics {
725 x: m.x, y: m.y, peak: m.peak, flux: m.flux,
726 fwhm_x: m.fwhm_x, fwhm_y: m.fwhm_y, fwhm: m.fwhm,
727 eccentricity: m.eccentricity, snr: m.snr, hfr: m.hfr,
728 theta: m.theta, beta: m.beta, fit_method: m.fit_method,
729 fit_residual: m.fit_residual,
730 })
731 .collect();
732
733 Ok(AnalysisResult {
734 width, height, source_channels: channels,
735 background: bg_result.background, noise: bg_result.noise,
736 detection_threshold, stars_detected, stars,
737 median_fwhm, median_eccentricity, median_snr, median_hfr,
738 snr_weight, psf_signal, frame_snr,
739 trail_r_squared, possibly_trailed,
740 measured_fwhm_kernel: final_fwhm,
741 median_beta,
742 })
743 }
744}
745
746impl Default for ImageAnalyzer {
747 fn default() -> Self {
748 Self::new()
749 }
750}
751
752pub fn sigma_clipped_median(values: &[f32]) -> f32 {
761 if values.is_empty() {
762 return 0.0;
763 }
764 let mut v: Vec<f32> = values.to_vec();
765 for _ in 0..2 {
766 if v.len() < 3 {
767 break;
768 }
769 let med = find_median(&mut v);
770 let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
771 let mad = find_median(&mut abs_devs);
772 let sigma_mad = 1.4826 * mad;
773 if sigma_mad < 1e-10 {
774 break; }
776 let clip = 3.0 * sigma_mad;
777 v.retain(|&x| (x - med).abs() <= clip);
778 }
779 if v.is_empty() {
780 return find_median(&mut values.to_vec());
781 }
782 find_median(&mut v)
783}
784
785pub fn weighted_median(values: &[f32], weights: &[f32]) -> f32 {
789 if values.is_empty() || values.len() != weights.len() {
790 return 0.0;
791 }
792 let mut pairs: Vec<(f32, f32)> = values.iter().copied()
793 .zip(weights.iter().copied())
794 .filter(|(_, w)| *w > 0.0)
795 .collect();
796 if pairs.is_empty() {
797 return 0.0;
798 }
799 pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
800 let total: f32 = pairs.iter().map(|(_, w)| w).sum();
801 if total <= 0.0 {
802 return 0.0;
803 }
804 let half = total * 0.5;
805 let mut cumulative = 0.0_f32;
806 for &(val, w) in &pairs {
807 cumulative += w;
808 if cumulative >= half {
809 return val;
810 }
811 }
812 pairs.last().unwrap().0
813}
814
815pub fn sigma_clipped_weighted_median(values: &[f32], weights: &[f32]) -> f32 {
820 if values.is_empty() || values.len() != weights.len() {
821 return 0.0;
822 }
823 let mut v: Vec<f32> = values.to_vec();
824 let mut w: Vec<f32> = weights.to_vec();
825 for _ in 0..2 {
826 if v.len() < 3 {
827 break;
828 }
829 let med = weighted_median(&v, &w);
830 let abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
831 let mut sorted_devs = abs_devs.clone();
833 sorted_devs.sort_by(|a, b| a.total_cmp(b));
834 let mad = sorted_devs[sorted_devs.len() / 2];
835 let sigma_mad = 1.4826 * mad;
836 if sigma_mad < 1e-10 {
837 break;
838 }
839 let clip = 3.0 * sigma_mad;
840 let mut new_v = Vec::with_capacity(v.len());
841 let mut new_w = Vec::with_capacity(w.len());
842 for (val, wt) in v.iter().zip(w.iter()) {
843 if (*val - med).abs() <= clip {
844 new_v.push(*val);
845 new_w.push(*wt);
846 }
847 }
848 v = new_v;
849 w = new_w;
850 }
851 if v.is_empty() {
852 return weighted_median(values, weights);
853 }
854 weighted_median(&v, &w)
855}
856
857pub fn estimate_fwhm_from_stars(
861 lum: &[f32],
862 width: usize,
863 height: usize,
864 stars: &[detection::DetectedStar],
865 background: f32,
866 bg_map: Option<&[f32]>,
867) -> f32 {
868 let scan_n = stars.len().min(50);
872 if scan_n < 3 {
873 return 0.0;
874 }
875
876 let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
877 .iter()
878 .filter(|s| s.eccentricity <= 0.7)
879 .take(20)
880 .collect();
881 if round_stars.len() < 3 {
882 return 0.0;
883 }
884
885 let mut fwhm_vals = Vec::with_capacity(round_stars.len());
886 for star in &round_stars {
887 let stamp_radius = 12_usize; let cx = star.x.round() as i32;
889 let cy = star.y.round() as i32;
890 let sr = stamp_radius as i32;
891 if cx - sr <= 0 || cy - sr <= 0
892 || cx + sr >= width as i32 - 1
893 || cy + sr >= height as i32 - 1
894 {
895 continue;
896 }
897 let x0 = (cx - sr) as usize;
898 let y0 = (cy - sr) as usize;
899 let x1 = (cx + sr) as usize;
900 let y1 = (cy + sr) as usize;
901 let stamp_w = x1 - x0 + 1;
902 let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
903 for sy in y0..=y1 {
904 for sx in x0..=x1 {
905 let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
906 stamp.push(lum[sy * width + sx] - bg);
907 }
908 }
909 let rel_cx = star.x - x0 as f32;
910 let rel_cy = star.y - y0 as f32;
911 let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
912 let fwhm = sigma * 2.3548;
913 if fwhm > 1.0 && fwhm < 20.0 {
914 fwhm_vals.push(fwhm);
915 }
916 }
917
918 if fwhm_vals.len() < 3 {
919 return 0.0;
920 }
921 find_median(&mut fwhm_vals)
922}
923
924fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
930 let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
931 (0..height)
932 .flat_map(|y| {
933 (0..width).map(move |x| {
934 let parity = (x + y) & 1;
935 if green_at_even { parity == 0 } else { parity == 1 }
936 })
937 })
938 .collect()
939}
940
941pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
943 use rayon::prelude::*;
944
945 let plane_size = width * height;
946 let r = &data[..plane_size];
947 let g = &data[plane_size..2 * plane_size];
948 let b = &data[2 * plane_size..3 * plane_size];
949
950 let mut lum = vec![0.0_f32; plane_size];
951 const CHUNK: usize = 8192;
952 lum.par_chunks_mut(CHUNK)
953 .enumerate()
954 .for_each(|(ci, chunk)| {
955 let off = ci * CHUNK;
956 for (i, dst) in chunk.iter_mut().enumerate() {
957 let idx = off + i;
958 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
959 }
960 });
961 lum
962}
963
964#[cfg(feature = "debug-pipeline")]
970pub fn prepare_luminance(
971 meta: &crate::types::ImageMetadata,
972 pixels: &crate::types::PixelData,
973 apply_debayer: bool,
974) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
975 use crate::processing::color::u16_to_f32;
976 use crate::processing::debayer;
977
978 let f32_data = match pixels {
979 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
980 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
981 };
982
983 let mut data = f32_data.into_owned();
984 let width = meta.width;
985 let height = meta.height;
986 let channels = meta.channels;
987
988 let green_mask = if apply_debayer
989 && meta.bayer_pattern != BayerPattern::None
990 && channels == 1
991 {
992 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
993 Some(build_green_mask(width, height, meta.bayer_pattern))
994 } else {
995 None
996 };
997
998 let lum = if channels == 3 {
999 extract_luminance(&data, width, height)
1000 } else {
1001 data[..width * height].to_vec()
1002 };
1003
1004 (lum, width, height, channels, green_mask)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010
1011 #[test]
1012 fn test_weighted_median_equal_weights() {
1013 let vals = [1.0_f32, 3.0, 5.0, 7.0, 9.0];
1015 let wts = [1.0_f32; 5];
1016 let wm = weighted_median(&vals, &wts);
1017 assert!((wm - 5.0).abs() < 0.01, "Equal-weight median should be 5, got {}", wm);
1018 }
1019
1020 #[test]
1021 fn test_weighted_median_skewed_weights() {
1022 let vals = [1.0_f32, 10.0];
1024 let wts = [9.0_f32, 1.0]; let wm = weighted_median(&vals, &wts);
1026 assert!((wm - 1.0).abs() < 0.01, "Skewed-weight median should be 1, got {}", wm);
1027 }
1028
1029 #[test]
1030 fn test_weighted_median_empty() {
1031 let wm = weighted_median(&[], &[]);
1032 assert_eq!(wm, 0.0);
1033 }
1034
1035 #[test]
1036 fn test_weighted_median_single() {
1037 let wm = weighted_median(&[42.0], &[1.0]);
1038 assert!((wm - 42.0).abs() < 0.01);
1039 }
1040
1041 #[test]
1042 fn test_sigma_clipped_weighted_median_basic() {
1043 let vals = [3.0_f32, 3.1, 3.0, 3.2, 3.0, 100.0]; let wts = [1.0_f32; 6];
1046 let scwm = sigma_clipped_weighted_median(&vals, &wts);
1047 assert!(scwm < 4.0, "Outlier should be clipped, got {}", scwm);
1048 }
1049}