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 pub fwhm_arcsec: Option<f32>,
97 pub hfr_arcsec: Option<f32>,
99}
100
101pub struct StageTiming {
103 pub background_ms: f64,
104 pub detection_pass1_ms: f64,
105 pub calibration_ms: f64,
106 pub detection_pass2_ms: f64,
107 pub measurement_ms: f64,
108 pub snr_ms: f64,
109 pub statistics_ms: f64,
110 pub total_ms: f64,
111}
112
113pub struct AnalysisResult {
115 pub width: usize,
117 pub height: usize,
119 pub source_channels: usize,
121 pub background: f32,
123 pub noise: f32,
125 pub detection_threshold: f32,
127 pub stars_detected: usize,
129 pub stars: Vec<StarMetrics>,
131 pub median_fwhm: f32,
133 pub median_eccentricity: f32,
135 pub median_snr: f32,
137 pub median_hfr: f32,
139 pub snr_weight: f32,
141 pub psf_signal: f32,
143 pub frame_snr: f32,
146 pub trail_r_squared: f32,
151 pub possibly_trailed: bool,
156 pub measured_fwhm_kernel: f32,
160 pub median_beta: Option<f32>,
163 pub plate_scale: Option<f32>,
165 pub median_fwhm_arcsec: Option<f32>,
167 pub median_hfr_arcsec: Option<f32>,
169 pub stage_timing: StageTiming,
171}
172
173pub struct AnalysisConfig {
175 detection_sigma: f32,
176 min_star_area: usize,
177 max_star_area: usize,
178 saturation_fraction: f32,
179 max_stars: usize,
180 apply_debayer: bool,
181 trail_r_squared_threshold: f32,
182 noise_layers: usize,
184 measure_cap: usize,
186 fit_max_iter: usize,
188 fit_tolerance: f64,
190 fit_max_rejects: usize,
192 focal_length_mm: Option<f64>,
194 pixel_size_um: Option<f64>,
196}
197
198pub struct ImageAnalyzer {
200 config: AnalysisConfig,
201 thread_pool: Option<Arc<rayon::ThreadPool>>,
202}
203
204impl ImageAnalyzer {
205 pub fn new() -> Self {
206 ImageAnalyzer {
207 config: AnalysisConfig {
208 detection_sigma: 5.0,
209 min_star_area: 5,
210 max_star_area: 2000,
211 saturation_fraction: 0.95,
212 max_stars: 200,
213 apply_debayer: true,
214 trail_r_squared_threshold: 0.5,
215 noise_layers: 0,
216 measure_cap: 500,
217 fit_max_iter: 25,
218 fit_tolerance: 1e-4,
219 fit_max_rejects: 5,
220 focal_length_mm: None,
221 pixel_size_um: None,
222 },
223 thread_pool: None,
224 }
225 }
226
227 pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
229 self.config.detection_sigma = sigma.max(1.0);
230 self
231 }
232
233 pub fn with_min_star_area(mut self, area: usize) -> Self {
235 self.config.min_star_area = area.max(1);
236 self
237 }
238
239 pub fn with_max_star_area(mut self, area: usize) -> Self {
241 self.config.max_star_area = area;
242 self
243 }
244
245 pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
247 self.config.saturation_fraction = frac.clamp(0.5, 1.0);
248 self
249 }
250
251 pub fn with_max_stars(mut self, n: usize) -> Self {
253 self.config.max_stars = n.max(1);
254 self
255 }
256
257 pub fn without_debayer(mut self) -> Self {
259 self.config.apply_debayer = false;
260 self
261 }
262
263 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
267 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
268 self
269 }
270
271 pub fn with_optics(mut self, focal_length_mm: f64, pixel_size_um: f64) -> Self {
276 self.config.focal_length_mm = Some(focal_length_mm);
277 self.config.pixel_size_um = Some(pixel_size_um);
278 self
279 }
280
281 pub fn with_mrs_layers(mut self, layers: usize) -> Self {
286 self.config.noise_layers = layers;
287 self
288 }
289
290 pub fn with_measure_cap(mut self, n: usize) -> Self {
294 self.config.measure_cap = n;
295 self
296 }
297
298 pub fn with_fit_max_iter(mut self, n: usize) -> Self {
301 self.config.fit_max_iter = n.max(1);
302 self
303 }
304
305 pub fn with_fit_tolerance(mut self, tol: f64) -> Self {
308 self.config.fit_tolerance = tol;
309 self
310 }
311
312 pub fn with_fit_max_rejects(mut self, n: usize) -> Self {
314 self.config.fit_max_rejects = n.max(1);
315 self
316 }
317
318 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
320 self.thread_pool = Some(pool);
321 self
322 }
323
324 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
326 let path = path.as_ref();
327 match &self.thread_pool {
328 Some(pool) => pool.install(|| self.analyze_impl(path)),
329 None => self.analyze_impl(path),
330 }
331 }
332
333 pub fn analyze_data(
340 &self,
341 data: &[f32],
342 width: usize,
343 height: usize,
344 channels: usize,
345 ) -> Result<AnalysisResult> {
346 match &self.thread_pool {
347 Some(pool) => pool.install(|| {
348 self.run_analysis(data, width, height, channels)
349 }),
350 None => self.run_analysis(data, width, height, channels),
351 }
352 }
353
354 pub fn analyze_raw(
359 &self,
360 meta: &ImageMetadata,
361 pixels: &PixelData,
362 ) -> Result<AnalysisResult> {
363 match &self.thread_pool {
364 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
365 None => self.analyze_raw_impl(meta, pixels),
366 }
367 }
368
369 pub fn analyze_batch<P, F>(
375 &self,
376 paths: &[P],
377 concurrency: usize,
378 progress: F,
379 ) -> Vec<(std::path::PathBuf, Result<AnalysisResult>)>
380 where
381 P: AsRef<std::path::Path> + Sync,
382 F: Fn(usize, usize, &std::path::Path) + Send + Sync,
383 {
384 use std::sync::atomic::{AtomicUsize, Ordering};
385 use rayon::prelude::*;
386
387 let total = paths.len();
388 let completed = AtomicUsize::new(0);
389 let concurrency = concurrency.max(1);
390
391 let do_batch = || {
392 let mut results = Vec::with_capacity(total);
393 for chunk in paths.chunks(concurrency) {
394 let chunk_results: Vec<_> = chunk
395 .into_par_iter()
396 .map(|p| {
397 let path = p.as_ref();
398 let result = self.analyze_impl(path);
399 let n = completed.fetch_add(1, Ordering::Relaxed) + 1;
400 progress(n, total, path);
401 (path.to_path_buf(), result)
402 })
403 .collect();
404 results.extend(chunk_results);
405 }
406 results
407 };
408
409 match &self.thread_pool {
410 Some(pool) => pool.install(do_batch),
411 None => do_batch(),
412 }
413 }
414
415 fn analyze_raw_impl(
416 &self,
417 meta: &ImageMetadata,
418 pixels: &PixelData,
419 ) -> Result<AnalysisResult> {
420 let f32_data = match pixels {
421 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
422 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
423 };
424
425 let mut data = f32_data.into_owned();
426 let width = meta.width;
427 let height = meta.height;
428 let channels = meta.channels;
429
430 if self.config.apply_debayer
433 && meta.bayer_pattern != BayerPattern::None
434 && channels == 1
435 {
436 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
437 }
438
439 self.run_analysis(&data, width, height, channels)
440 }
441
442 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
443 let (meta, pixel_data) =
444 formats::read_image(path).context("Failed to read image for analysis")?;
445
446 let f32_data = match pixel_data {
448 PixelData::Float32(d) => d,
449 PixelData::Uint16(d) => u16_to_f32(&d),
450 };
451
452 let mut data = f32_data;
453 let width = meta.width;
454 let height = meta.height;
455 let channels = meta.channels;
456
457 if self.config.apply_debayer
459 && meta.bayer_pattern != BayerPattern::None
460 && channels == 1
461 {
462 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
463 }
464
465 self.run_analysis(&data, width, height, channels)
466 }
467
468 fn run_analysis(
469 &self,
470 data: &[f32],
471 width: usize,
472 height: usize,
473 channels: usize,
474 ) -> Result<AnalysisResult> {
475 let pipeline_start = std::time::Instant::now();
476
477 let lum = if channels == 3 {
479 extract_luminance(data, width, height)
480 } else {
481 data[..width * height].to_vec()
482 };
483
484 let det_params = DetectionParams {
485 detection_sigma: self.config.detection_sigma,
486 min_star_area: self.config.min_star_area,
487 max_star_area: self.config.max_star_area,
488 saturation_limit: self.config.saturation_fraction * 65535.0,
489 };
490
491 let t = std::time::Instant::now();
493 let cell_size = background::auto_cell_size(width, height);
494 let mut bg_result = background::estimate_background_mesh(&lum, width, height, cell_size);
495 if self.config.noise_layers > 0 {
496 bg_result.noise = background::estimate_noise_mrs(
498 &lum, width, height, self.config.noise_layers.max(1),
499 ).max(0.001);
500 }
501 let background_ms = t.elapsed().as_secs_f64() * 1000.0;
503
504 let t = std::time::Instant::now();
506 let initial_fwhm = 3.0_f32;
507 let pass1_stars = {
508 let bg_map = bg_result.background_map.as_deref();
509 let noise_map = bg_result.noise_map.as_deref();
510 detection::detect_stars(
511 &lum, width, height,
512 bg_result.background, bg_result.noise,
513 bg_map, noise_map, &det_params, initial_fwhm,
514 None,
515 )
516 };
517 let detection_pass1_ms = t.elapsed().as_secs_f64() * 1000.0;
518
519 let t = std::time::Instant::now();
521 let calibration_stars: Vec<&detection::DetectedStar> = pass1_stars
522 .iter()
523 .filter(|s| s.eccentricity < 0.5 && s.area >= 5)
524 .take(100)
525 .collect();
526
527 let field_beta: Option<f64>;
529 let field_fwhm: f32;
530 if calibration_stars.len() >= 3 {
531 let cal_owned: Vec<detection::DetectedStar> = calibration_stars
532 .iter()
533 .map(|s| detection::DetectedStar {
534 x: s.x, y: s.y, peak: s.peak, flux: s.flux,
535 area: s.area, theta: s.theta, eccentricity: s.eccentricity,
536 })
537 .collect();
538 let cal_measured = metrics::measure_stars(
539 &lum, width, height, &cal_owned,
540 bg_result.background,
541 bg_result.background_map.as_deref(),
542 None, None, 50, 1e-6, 5, None, false, );
548
549 let mut beta_vals: Vec<f32> = cal_measured.iter().filter_map(|s| s.beta).collect();
550 let mut fwhm_vals: Vec<f32> = cal_measured.iter().map(|s| s.fwhm).collect();
551
552 if beta_vals.len() >= 3 {
553 field_beta = Some(sigma_clipped_median(&beta_vals) as f64);
554 } else if !beta_vals.is_empty() {
555 field_beta = Some(find_median(&mut beta_vals) as f64);
556 } else {
557 field_beta = None;
558 }
559
560 if fwhm_vals.len() >= 3 {
561 field_fwhm = sigma_clipped_median(&fwhm_vals);
562 } else if !fwhm_vals.is_empty() {
563 field_fwhm = find_median(&mut fwhm_vals);
564 } else {
565 field_fwhm = estimate_fwhm_from_stars(
566 &lum, width, height, &pass1_stars,
567 bg_result.background, bg_result.background_map.as_deref(),
568 );
569 }
570 } else {
571 field_beta = None;
573 field_fwhm = estimate_fwhm_from_stars(
574 &lum, width, height, &pass1_stars,
575 bg_result.background, bg_result.background_map.as_deref(),
576 );
577 }
578
579 let calibration_ms = t.elapsed().as_secs_f64() * 1000.0;
580
581 let t = std::time::Instant::now();
588 let clamped_fwhm = field_fwhm.max(2.0);
592 let final_fwhm = if clamped_fwhm > 1.0
593 && ((clamped_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
594 {
595 clamped_fwhm.min(initial_fwhm * 2.0)
596 } else {
597 initial_fwhm
598 };
599
600 let detected = {
601 let bg_map = bg_result.background_map.as_deref();
602 let noise_map = bg_result.noise_map.as_deref();
603 detection::detect_stars(
604 &lum, width, height,
605 bg_result.background, bg_result.noise,
606 bg_map, noise_map, &det_params, final_fwhm,
607 Some(clamped_fwhm),
608 )
609 };
610 let detection_pass2_ms = t.elapsed().as_secs_f64() * 1000.0;
611
612 let bg_map_ref = bg_result.background_map.as_deref();
613 let detection_threshold = self.config.detection_sigma * bg_result.noise;
614
615 let (trail_r_squared, possibly_trailed) = if detected.len() >= 20 {
619 let n = detected.len();
620 let (sum_cos, sum_sin) =
621 detected.iter().fold((0.0f64, 0.0f64), |(sc, ss), s| {
622 let a = 2.0 * s.theta as f64;
623 (sc + a.cos(), ss + a.sin())
624 });
625 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
626 let p = (-(n as f64) * r_sq).exp();
627 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
628 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
629 let median_ecc = if eccs.len() % 2 == 1 {
630 eccs[eccs.len() / 2]
631 } else {
632 (eccs[eccs.len() / 2 - 1] + eccs[eccs.len() / 2]) * 0.5
633 };
634 let threshold = self.config.trail_r_squared_threshold as f64;
635 let trailed = (r_sq > threshold && p < 0.01) || (r_sq > 0.05 && median_ecc > 0.6 && p < 0.05); (r_sq as f32, trailed)
638 } else {
639 (0.0, false)
640 };
641
642 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
643 let frame_snr = if bg_result.noise > 0.0 { bg_result.background / bg_result.noise } else { 0.0 };
644
645 let make_zero_result = |stars_detected: usize| {
646 Ok(AnalysisResult {
647 width, height, source_channels: channels,
648 background: bg_result.background, noise: bg_result.noise,
649 detection_threshold, stars_detected,
650 stars: Vec::new(),
651 median_fwhm: 0.0, median_eccentricity: 0.0,
652 median_snr: 0.0, median_hfr: 0.0,
653 snr_weight, psf_signal: 0.0, frame_snr,
654 trail_r_squared, possibly_trailed,
655 measured_fwhm_kernel: final_fwhm,
656 median_beta: field_beta.map(|b| b as f32),
657 plate_scale: None, median_fwhm_arcsec: None, median_hfr_arcsec: None,
658 stage_timing: StageTiming {
659 background_ms: 0.0, detection_pass1_ms: 0.0, calibration_ms: 0.0,
660 detection_pass2_ms: 0.0, measurement_ms: 0.0, snr_ms: 0.0,
661 statistics_ms: 0.0, total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
662 },
663 })
664 };
665
666 if detected.is_empty() {
667 return make_zero_result(0);
668 }
669
670 let t = std::time::Instant::now();
672 let stars_detected = detected.len();
673
674 let effective_cap = if self.config.measure_cap == 0 {
678 detected.len()
679 } else {
680 self.config.measure_cap
681 };
682
683 let to_measure: Vec<detection::DetectedStar> = if detected.len() <= effective_cap {
684 detected.clone()
685 } else {
686 debug_assert!(
687 detected.windows(2).all(|w| w[0].flux >= w[1].flux),
688 "detected stars must be sorted by flux descending"
689 );
690 const GRID_N: usize = 4;
691 let cell_w = width as f32 / GRID_N as f32;
692 let cell_h = height as f32 / GRID_N as f32;
693 let mut buckets: Vec<Vec<&detection::DetectedStar>> =
694 vec![Vec::new(); GRID_N * GRID_N];
695
696 for star in &detected {
697 let gx = ((star.x / cell_w) as usize).min(GRID_N - 1);
698 let gy = ((star.y / cell_h) as usize).min(GRID_N - 1);
699 buckets[gy * GRID_N + gx].push(star);
700 }
701
702 let mut selected: Vec<detection::DetectedStar> = Vec::with_capacity(effective_cap);
703 let mut idx = vec![0usize; GRID_N * GRID_N];
704 loop {
705 let mut added_any = false;
706 for cell in 0..(GRID_N * GRID_N) {
707 if selected.len() >= effective_cap { break; }
708 if idx[cell] < buckets[cell].len() {
709 selected.push(buckets[cell][idx[cell]].clone());
710 idx[cell] += 1;
711 added_any = true;
712 }
713 }
714 if !added_any || selected.len() >= effective_cap { break; }
715 }
716 selected
717 };
718
719 let mut measured = metrics::measure_stars(
720 &lum, width, height, &to_measure,
721 bg_result.background, bg_map_ref,
722 None, field_beta, self.config.fit_max_iter,
724 self.config.fit_tolerance,
725 self.config.fit_max_rejects,
726 Some(field_fwhm), possibly_trailed, );
729 let measurement_ms = t.elapsed().as_secs_f64() * 1000.0;
730
731 if measured.is_empty() {
732 return make_zero_result(stars_detected);
733 }
734
735 let t = std::time::Instant::now();
737
738 const FWHM_ECC_MAX: f32 = 0.8;
745 let fwhm_filtered: Vec<&metrics::MeasuredStar> = if possibly_trailed {
746 measured.iter().collect()
747 } else {
748 let round: Vec<&metrics::MeasuredStar> = measured.iter()
749 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
750 .collect();
751 if round.len() >= 3 { round } else { measured.iter().collect() }
752 };
753 let (fwhm_vals, hfr_vals, shape_weights) = (
754 fwhm_filtered.iter().map(|s| s.fwhm).collect::<Vec<f32>>(),
755 fwhm_filtered.iter().map(|s| s.hfr).collect::<Vec<f32>>(),
756 fwhm_filtered.iter().map(|s| 1.0 / (1.0 + s.fit_residual)).collect::<Vec<f32>>(),
757 );
758 let median_fwhm = sigma_clipped_weighted_median(&fwhm_vals, &shape_weights);
759
760 let ecc_use_all = possibly_trailed;
764 let ecc_filtered: Vec<&metrics::MeasuredStar> = if ecc_use_all {
765 measured.iter().collect()
766 } else {
767 let filtered: Vec<&metrics::MeasuredStar> = measured.iter()
768 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
769 .collect();
770 if filtered.len() >= 3 { filtered } else { measured.iter().collect() }
771 };
772 let ecc_vals: Vec<f32> = ecc_filtered.iter().map(|s| s.eccentricity).collect();
773 let ecc_weights: Vec<f32> = ecc_filtered.iter()
774 .map(|s| 1.0 / (1.0 + s.fit_residual))
775 .collect();
776
777 let statistics_ms_before_snr = t.elapsed().as_secs_f64() * 1000.0;
778
779 let t = std::time::Instant::now();
780 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
781 let snr_ms = t.elapsed().as_secs_f64() * 1000.0;
782
783 let t = std::time::Instant::now();
784 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
785
786 let median_eccentricity = sigma_clipped_weighted_median(&ecc_vals, &ecc_weights);
787 let median_snr = find_median(&mut snr_vals);
788 let median_hfr = sigma_clipped_weighted_median(&hfr_vals, &shape_weights);
789 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
790
791 let median_beta = if let Some(fb) = field_beta {
793 Some(fb as f32)
794 } else {
795 let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
796 if beta_vals.is_empty() { None } else { Some(find_median(&mut beta_vals)) }
797 };
798
799 let plate_scale = match (self.config.focal_length_mm, self.config.pixel_size_um) {
800 (Some(fl), Some(ps)) if fl > 0.0 && ps > 0.0 => {
801 Some((ps / fl * 206.265) as f32)
802 }
803 _ => None,
804 };
805
806 let median_fwhm_arcsec = plate_scale.map(|s| median_fwhm * s);
807 let median_hfr_arcsec = plate_scale.map(|s| median_hfr * s);
808
809 measured.truncate(self.config.max_stars);
811
812 let stars: Vec<StarMetrics> = measured
813 .into_iter()
814 .map(|m| StarMetrics {
815 x: m.x, y: m.y, peak: m.peak, flux: m.flux,
816 fwhm_x: m.fwhm_x, fwhm_y: m.fwhm_y, fwhm: m.fwhm,
817 eccentricity: m.eccentricity, snr: m.snr, hfr: m.hfr,
818 theta: m.theta, beta: m.beta, fit_method: m.fit_method,
819 fit_residual: m.fit_residual,
820 fwhm_arcsec: plate_scale.map(|s| m.fwhm * s),
821 hfr_arcsec: plate_scale.map(|s| m.hfr * s),
822 })
823 .collect();
824 let statistics_ms = statistics_ms_before_snr + t.elapsed().as_secs_f64() * 1000.0;
825 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
826
827 Ok(AnalysisResult {
828 width, height, source_channels: channels,
829 background: bg_result.background, noise: bg_result.noise,
830 detection_threshold, stars_detected, stars,
831 median_fwhm, median_eccentricity, median_snr, median_hfr,
832 snr_weight, psf_signal, frame_snr,
833 trail_r_squared, possibly_trailed,
834 measured_fwhm_kernel: final_fwhm,
835 median_beta,
836 plate_scale, median_fwhm_arcsec, median_hfr_arcsec,
837 stage_timing: StageTiming {
838 background_ms, detection_pass1_ms, calibration_ms,
839 detection_pass2_ms, measurement_ms, snr_ms,
840 statistics_ms, total_ms,
841 },
842 })
843 }
844}
845
846impl Default for ImageAnalyzer {
847 fn default() -> Self {
848 Self::new()
849 }
850}
851
852pub fn sigma_clipped_median(values: &[f32]) -> f32 {
861 if values.is_empty() {
862 return 0.0;
863 }
864 let mut v: Vec<f32> = values.to_vec();
865 for _ in 0..2 {
866 if v.len() < 3 {
867 break;
868 }
869 let med = find_median(&mut v);
870 let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
871 let mad = find_median(&mut abs_devs);
872 let sigma_mad = 1.4826 * mad;
873 if sigma_mad < 1e-10 {
874 break; }
876 let clip = 3.0 * sigma_mad;
877 v.retain(|&x| (x - med).abs() <= clip);
878 }
879 if v.is_empty() {
880 return find_median(&mut values.to_vec());
881 }
882 find_median(&mut v)
883}
884
885pub fn weighted_median(values: &[f32], weights: &[f32]) -> f32 {
889 if values.is_empty() || values.len() != weights.len() {
890 return 0.0;
891 }
892 let mut pairs: Vec<(f32, f32)> = values.iter().copied()
893 .zip(weights.iter().copied())
894 .filter(|(_, w)| *w > 0.0)
895 .collect();
896 if pairs.is_empty() {
897 return 0.0;
898 }
899 pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
900 let total: f32 = pairs.iter().map(|(_, w)| w).sum();
901 if total <= 0.0 {
902 return 0.0;
903 }
904 let half = total * 0.5;
905 let mut cumulative = 0.0_f32;
906 for &(val, w) in &pairs {
907 cumulative += w;
908 if cumulative >= half {
909 return val;
910 }
911 }
912 pairs.last().unwrap().0
913}
914
915pub fn sigma_clipped_weighted_median(values: &[f32], weights: &[f32]) -> f32 {
920 if values.is_empty() || values.len() != weights.len() {
921 return 0.0;
922 }
923 let mut v: Vec<f32> = values.to_vec();
924 let mut w: Vec<f32> = weights.to_vec();
925 for _ in 0..2 {
926 if v.len() < 3 {
927 break;
928 }
929 let med = weighted_median(&v, &w);
930 let abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
931 let mut sorted_devs = abs_devs.clone();
933 sorted_devs.sort_by(|a, b| a.total_cmp(b));
934 let mad = sorted_devs[sorted_devs.len() / 2];
935 let sigma_mad = 1.4826 * mad;
936 if sigma_mad < 1e-10 {
937 break;
938 }
939 let clip = 3.0 * sigma_mad;
940 let mut new_v = Vec::with_capacity(v.len());
941 let mut new_w = Vec::with_capacity(w.len());
942 for (val, wt) in v.iter().zip(w.iter()) {
943 if (*val - med).abs() <= clip {
944 new_v.push(*val);
945 new_w.push(*wt);
946 }
947 }
948 v = new_v;
949 w = new_w;
950 }
951 if v.is_empty() {
952 return weighted_median(values, weights);
953 }
954 weighted_median(&v, &w)
955}
956
957pub fn estimate_fwhm_from_stars(
961 lum: &[f32],
962 width: usize,
963 height: usize,
964 stars: &[detection::DetectedStar],
965 background: f32,
966 bg_map: Option<&[f32]>,
967) -> f32 {
968 let scan_n = stars.len().min(50);
972 if scan_n < 3 {
973 return 0.0;
974 }
975
976 let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
977 .iter()
978 .filter(|s| s.eccentricity <= 0.7)
979 .take(20)
980 .collect();
981 if round_stars.len() < 3 {
982 return 0.0;
983 }
984
985 let mut fwhm_vals = Vec::with_capacity(round_stars.len());
986 for star in &round_stars {
987 let stamp_radius = 12_usize; let cx = star.x.round() as i32;
989 let cy = star.y.round() as i32;
990 let sr = stamp_radius as i32;
991 if cx - sr <= 0 || cy - sr <= 0
992 || cx + sr >= width as i32 - 1
993 || cy + sr >= height as i32 - 1
994 {
995 continue;
996 }
997 let x0 = (cx - sr) as usize;
998 let y0 = (cy - sr) as usize;
999 let x1 = (cx + sr) as usize;
1000 let y1 = (cy + sr) as usize;
1001 let stamp_w = x1 - x0 + 1;
1002 let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
1003 for sy in y0..=y1 {
1004 for sx in x0..=x1 {
1005 let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
1006 stamp.push(lum[sy * width + sx] - bg);
1007 }
1008 }
1009 let rel_cx = star.x - x0 as f32;
1010 let rel_cy = star.y - y0 as f32;
1011 let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
1012 let fwhm = sigma * 2.3548;
1013 if fwhm > 1.0 && fwhm < 20.0 {
1014 fwhm_vals.push(fwhm);
1015 }
1016 }
1017
1018 if fwhm_vals.len() < 3 {
1019 return 0.0;
1020 }
1021 find_median(&mut fwhm_vals)
1022}
1023
1024pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
1029 use rayon::prelude::*;
1030
1031 let plane_size = width * height;
1032 let r = &data[..plane_size];
1033 let g = &data[plane_size..2 * plane_size];
1034 let b = &data[2 * plane_size..3 * plane_size];
1035
1036 let mut lum = vec![0.0_f32; plane_size];
1037 const CHUNK: usize = 8192;
1038 lum.par_chunks_mut(CHUNK)
1039 .enumerate()
1040 .for_each(|(ci, chunk)| {
1041 let off = ci * CHUNK;
1042 for (i, dst) in chunk.iter_mut().enumerate() {
1043 let idx = off + i;
1044 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
1045 }
1046 });
1047 lum
1048}
1049
1050#[cfg(feature = "debug-pipeline")]
1056pub fn prepare_luminance(
1057 meta: &crate::types::ImageMetadata,
1058 pixels: &crate::types::PixelData,
1059 apply_debayer: bool,
1060) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
1061 use crate::processing::color::u16_to_f32;
1062 use crate::processing::debayer;
1063
1064 let f32_data = match pixels {
1065 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
1066 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
1067 };
1068
1069 let mut data = f32_data.into_owned();
1070 let width = meta.width;
1071 let height = meta.height;
1072 let channels = meta.channels;
1073
1074 if apply_debayer
1077 && meta.bayer_pattern != BayerPattern::None
1078 && channels == 1
1079 {
1080 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
1081 }
1082 let green_mask: Option<Vec<bool>> = None;
1083
1084 let lum = if channels == 3 {
1085 extract_luminance(&data, width, height)
1086 } else {
1087 data[..width * height].to_vec()
1088 };
1089
1090 (lum, width, height, channels, green_mask)
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 #[test]
1098 fn test_weighted_median_equal_weights() {
1099 let vals = [1.0_f32, 3.0, 5.0, 7.0, 9.0];
1101 let wts = [1.0_f32; 5];
1102 let wm = weighted_median(&vals, &wts);
1103 assert!((wm - 5.0).abs() < 0.01, "Equal-weight median should be 5, got {}", wm);
1104 }
1105
1106 #[test]
1107 fn test_weighted_median_skewed_weights() {
1108 let vals = [1.0_f32, 10.0];
1110 let wts = [9.0_f32, 1.0]; let wm = weighted_median(&vals, &wts);
1112 assert!((wm - 1.0).abs() < 0.01, "Skewed-weight median should be 1, got {}", wm);
1113 }
1114
1115 #[test]
1116 fn test_weighted_median_empty() {
1117 let wm = weighted_median(&[], &[]);
1118 assert_eq!(wm, 0.0);
1119 }
1120
1121 #[test]
1122 fn test_weighted_median_single() {
1123 let wm = weighted_median(&[42.0], &[1.0]);
1124 assert!((wm - 42.0).abs() < 0.01);
1125 }
1126
1127 #[test]
1128 fn test_sigma_clipped_weighted_median_basic() {
1129 let vals = [3.0_f32, 3.1, 3.0, 3.2, 3.0, 100.0]; let wts = [1.0_f32; 6];
1132 let scwm = sigma_clipped_weighted_median(&vals, &wts);
1133 assert!(scwm < 4.0, "Outlier should be clipped, got {}", scwm);
1134 }
1135}