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 pass1_detections: usize,
165 pub calibrated_fwhm: f32,
167 pub stars_measured: usize,
169 pub moffat_count: usize,
171 pub gaussian_count: usize,
173 pub plate_scale: Option<f32>,
175 pub median_fwhm_arcsec: Option<f32>,
177 pub median_hfr_arcsec: Option<f32>,
179 pub stage_timing: StageTiming,
181}
182
183pub struct AnalysisConfig {
185 detection_sigma: f32,
186 min_star_area: usize,
187 max_star_area: usize,
188 saturation_fraction: f32,
189 max_stars: usize,
190 apply_debayer: bool,
191 trail_r_squared_threshold: f32,
192 noise_layers: usize,
194 measure_cap: usize,
196 fit_max_iter: usize,
198 fit_tolerance: f64,
200 fit_max_rejects: usize,
202 focal_length_mm: Option<f64>,
204 pixel_size_um: Option<f64>,
206}
207
208pub struct ImageAnalyzer {
210 config: AnalysisConfig,
211 thread_pool: Option<Arc<rayon::ThreadPool>>,
212}
213
214impl ImageAnalyzer {
215 pub fn new() -> Self {
216 ImageAnalyzer {
217 config: AnalysisConfig {
218 detection_sigma: 5.0,
219 min_star_area: 5,
220 max_star_area: 2000,
221 saturation_fraction: 0.95,
222 max_stars: 200,
223 apply_debayer: true,
224 trail_r_squared_threshold: 0.5,
225 noise_layers: 0,
226 measure_cap: 500,
227 fit_max_iter: 25,
228 fit_tolerance: 1e-4,
229 fit_max_rejects: 5,
230 focal_length_mm: None,
231 pixel_size_um: None,
232 },
233 thread_pool: None,
234 }
235 }
236
237 pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
239 self.config.detection_sigma = sigma.max(1.0);
240 self
241 }
242
243 pub fn with_min_star_area(mut self, area: usize) -> Self {
245 self.config.min_star_area = area.max(1);
246 self
247 }
248
249 pub fn with_max_star_area(mut self, area: usize) -> Self {
251 self.config.max_star_area = area;
252 self
253 }
254
255 pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
257 self.config.saturation_fraction = frac.clamp(0.5, 1.0);
258 self
259 }
260
261 pub fn with_max_stars(mut self, n: usize) -> Self {
263 self.config.max_stars = n.max(1);
264 self
265 }
266
267 pub fn without_debayer(mut self) -> Self {
269 self.config.apply_debayer = false;
270 self
271 }
272
273 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
277 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
278 self
279 }
280
281 pub fn with_optics(mut self, focal_length_mm: f64, pixel_size_um: f64) -> Self {
286 self.config.focal_length_mm = Some(focal_length_mm);
287 self.config.pixel_size_um = Some(pixel_size_um);
288 self
289 }
290
291 pub fn with_mrs_layers(mut self, layers: usize) -> Self {
296 self.config.noise_layers = layers;
297 self
298 }
299
300 pub fn with_measure_cap(mut self, n: usize) -> Self {
304 self.config.measure_cap = n;
305 self
306 }
307
308 pub fn with_fit_max_iter(mut self, n: usize) -> Self {
311 self.config.fit_max_iter = n.max(1);
312 self
313 }
314
315 pub fn with_fit_tolerance(mut self, tol: f64) -> Self {
318 self.config.fit_tolerance = tol;
319 self
320 }
321
322 pub fn with_fit_max_rejects(mut self, n: usize) -> Self {
324 self.config.fit_max_rejects = n.max(1);
325 self
326 }
327
328 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
330 self.thread_pool = Some(pool);
331 self
332 }
333
334 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
336 let path = path.as_ref();
337 match &self.thread_pool {
338 Some(pool) => pool.install(|| self.analyze_impl(path)),
339 None => self.analyze_impl(path),
340 }
341 }
342
343 pub fn analyze_data(
350 &self,
351 data: &[f32],
352 width: usize,
353 height: usize,
354 channels: usize,
355 ) -> Result<AnalysisResult> {
356 match &self.thread_pool {
357 Some(pool) => pool.install(|| {
358 self.run_analysis(data, width, height, channels)
359 }),
360 None => self.run_analysis(data, width, height, channels),
361 }
362 }
363
364 pub fn analyze_raw(
369 &self,
370 meta: &ImageMetadata,
371 pixels: &PixelData,
372 ) -> Result<AnalysisResult> {
373 match &self.thread_pool {
374 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
375 None => self.analyze_raw_impl(meta, pixels),
376 }
377 }
378
379 pub fn analyze_batch<P, F>(
385 &self,
386 paths: &[P],
387 concurrency: usize,
388 progress: F,
389 ) -> Vec<(std::path::PathBuf, Result<AnalysisResult>)>
390 where
391 P: AsRef<std::path::Path> + Sync,
392 F: Fn(usize, usize, &std::path::Path) + Send + Sync,
393 {
394 use std::sync::atomic::{AtomicUsize, Ordering};
395 use rayon::prelude::*;
396
397 let total = paths.len();
398 let completed = AtomicUsize::new(0);
399 let concurrency = concurrency.max(1);
400
401 let do_batch = || {
402 let mut results = Vec::with_capacity(total);
403 for chunk in paths.chunks(concurrency) {
404 let chunk_results: Vec<_> = chunk
405 .into_par_iter()
406 .map(|p| {
407 let path = p.as_ref();
408 let result = self.analyze_impl(path);
409 let n = completed.fetch_add(1, Ordering::Relaxed) + 1;
410 progress(n, total, path);
411 (path.to_path_buf(), result)
412 })
413 .collect();
414 results.extend(chunk_results);
415 }
416 results
417 };
418
419 match &self.thread_pool {
420 Some(pool) => pool.install(do_batch),
421 None => do_batch(),
422 }
423 }
424
425 fn analyze_raw_impl(
426 &self,
427 meta: &ImageMetadata,
428 pixels: &PixelData,
429 ) -> Result<AnalysisResult> {
430 let f32_data = match pixels {
431 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
432 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
433 };
434
435 let mut data = f32_data.into_owned();
436 let width = meta.width;
437 let height = meta.height;
438 let channels = meta.channels;
439
440 if self.config.apply_debayer
443 && meta.bayer_pattern != BayerPattern::None
444 && channels == 1
445 {
446 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
447 }
448
449 self.run_analysis(&data, width, height, channels)
450 }
451
452 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
453 let (meta, pixel_data) =
454 formats::read_image(path).context("Failed to read image for analysis")?;
455
456 let f32_data = match pixel_data {
458 PixelData::Float32(d) => d,
459 PixelData::Uint16(d) => u16_to_f32(&d),
460 };
461
462 let mut data = f32_data;
463 let width = meta.width;
464 let height = meta.height;
465 let channels = meta.channels;
466
467 if self.config.apply_debayer
469 && meta.bayer_pattern != BayerPattern::None
470 && channels == 1
471 {
472 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
473 }
474
475 self.run_analysis(&data, width, height, channels)
476 }
477
478 fn run_analysis(
479 &self,
480 data: &[f32],
481 width: usize,
482 height: usize,
483 channels: usize,
484 ) -> Result<AnalysisResult> {
485 let pipeline_start = std::time::Instant::now();
486
487 let lum = if channels == 3 {
489 extract_luminance(data, width, height)
490 } else {
491 data[..width * height].to_vec()
492 };
493
494 let det_params = DetectionParams {
495 detection_sigma: self.config.detection_sigma,
496 min_star_area: self.config.min_star_area,
497 max_star_area: self.config.max_star_area,
498 saturation_limit: self.config.saturation_fraction * 65535.0,
499 };
500
501 let t = std::time::Instant::now();
503 let cell_size = background::auto_cell_size(width, height);
504 let mut bg_result = background::estimate_background_mesh(&lum, width, height, cell_size);
505 if self.config.noise_layers > 0 {
506 bg_result.noise = background::estimate_noise_mrs(
508 &lum, width, height, self.config.noise_layers.max(1),
509 ).max(0.001);
510 }
511 let background_ms = t.elapsed().as_secs_f64() * 1000.0;
513
514 let t = std::time::Instant::now();
516 let initial_fwhm = 3.0_f32;
517 let pass1_stars = {
518 let bg_map = bg_result.background_map.as_deref();
519 let noise_map = bg_result.noise_map.as_deref();
520 detection::detect_stars(
521 &lum, width, height,
522 bg_result.background, bg_result.noise,
523 bg_map, noise_map, &det_params, initial_fwhm,
524 None,
525 )
526 };
527 let detection_pass1_ms = t.elapsed().as_secs_f64() * 1000.0;
528
529 let t = std::time::Instant::now();
531 let calibration_stars: Vec<&detection::DetectedStar> = pass1_stars
532 .iter()
533 .filter(|s| s.eccentricity < 0.5 && s.area >= 5)
534 .take(100)
535 .collect();
536
537 let field_beta: Option<f64>;
541 let field_fwhm: f32;
542 if calibration_stars.len() >= 3 {
543 let cal_owned: Vec<detection::DetectedStar> = calibration_stars
544 .iter()
545 .map(|s| detection::DetectedStar {
546 x: s.x, y: s.y, peak: s.peak, flux: s.flux,
547 area: s.area, theta: s.theta, eccentricity: s.eccentricity,
548 })
549 .collect();
550 let cal_measured = metrics::measure_stars(
551 &lum, width, height, &cal_owned,
552 bg_result.background,
553 bg_result.background_map.as_deref(),
554 None, None, 50, 1e-6, 5, None, );
559
560 let mut beta_vals: Vec<f32> = cal_measured.iter().filter_map(|s| s.beta).collect();
561 let mut fwhm_vals: Vec<f32> = cal_measured.iter().map(|s| s.fwhm).collect();
562
563 if beta_vals.len() >= 3 {
564 field_beta = Some(sigma_clipped_median(&beta_vals) as f64);
565 } else if !beta_vals.is_empty() {
566 field_beta = Some(find_median(&mut beta_vals) as f64);
567 } else {
568 field_beta = None;
569 }
570
571 if fwhm_vals.len() >= 3 {
572 field_fwhm = sigma_clipped_median(&fwhm_vals);
573 } else if !fwhm_vals.is_empty() {
574 field_fwhm = find_median(&mut fwhm_vals);
575 } else {
576 field_fwhm = estimate_fwhm_from_stars(
577 &lum, width, height, &pass1_stars,
578 bg_result.background, bg_result.background_map.as_deref(),
579 );
580 }
581 } else {
582 field_beta = None;
585 field_fwhm = estimate_fwhm_from_stars(
586 &lum, width, height, &pass1_stars,
587 bg_result.background, bg_result.background_map.as_deref(),
588 );
589 }
590
591 let calibration_ms = t.elapsed().as_secs_f64() * 1000.0;
592
593 let t = std::time::Instant::now();
600 let clamped_fwhm = field_fwhm.max(2.0);
604 let final_fwhm = if clamped_fwhm > 1.0
605 && ((clamped_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
606 {
607 clamped_fwhm.min(initial_fwhm * 6.0)
608 } else {
609 initial_fwhm
610 };
611
612 let detected = {
613 let bg_map = bg_result.background_map.as_deref();
614 let noise_map = bg_result.noise_map.as_deref();
615 detection::detect_stars(
616 &lum, width, height,
617 bg_result.background, bg_result.noise,
618 bg_map, noise_map, &det_params, final_fwhm,
619 Some(clamped_fwhm),
620 )
621 };
622 let detection_pass2_ms = t.elapsed().as_secs_f64() * 1000.0;
623
624 let bg_map_ref = bg_result.background_map.as_deref();
625 let detection_threshold = self.config.detection_sigma * bg_result.noise;
626
627 let (trail_r_squared, possibly_trailed) = if detected.len() >= 20 {
631 let n = detected.len();
632 let (sum_cos, sum_sin) =
633 detected.iter().fold((0.0f64, 0.0f64), |(sc, ss), s| {
634 let a = 2.0 * s.theta as f64;
635 (sc + a.cos(), ss + a.sin())
636 });
637 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
638 let p = (-(n as f64) * r_sq).exp();
639 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
640 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
641 let median_ecc = if eccs.len() % 2 == 1 {
642 eccs[eccs.len() / 2]
643 } else {
644 (eccs[eccs.len() / 2 - 1] + eccs[eccs.len() / 2]) * 0.5
645 };
646 let threshold = self.config.trail_r_squared_threshold as f64;
647 let trailed = (r_sq > threshold && p < 0.01) || (r_sq > 0.05 && median_ecc > 0.6 && p < 0.05); (r_sq as f32, trailed)
650 } else {
651 (0.0, false)
652 };
653
654 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
655 let frame_snr = if bg_result.noise > 0.0 { bg_result.background / bg_result.noise } else { 0.0 };
656
657 let pass1_detections = pass1_stars.len();
658
659 let make_zero_result = |stars_detected: usize| {
660 Ok(AnalysisResult {
661 width, height, source_channels: channels,
662 background: bg_result.background, noise: bg_result.noise,
663 detection_threshold, stars_detected,
664 stars: Vec::new(),
665 median_fwhm: 0.0, median_eccentricity: 0.0,
666 median_snr: 0.0, median_hfr: 0.0,
667 snr_weight, psf_signal: 0.0, frame_snr,
668 trail_r_squared, possibly_trailed,
669 measured_fwhm_kernel: final_fwhm,
670 median_beta: field_beta.map(|b| b as f32),
671 pass1_detections: 0, calibrated_fwhm: 0.0,
672 stars_measured: 0, moffat_count: 0, gaussian_count: 0,
673 plate_scale: None, median_fwhm_arcsec: None, median_hfr_arcsec: None,
674 stage_timing: StageTiming {
675 background_ms: 0.0, detection_pass1_ms: 0.0, calibration_ms: 0.0,
676 detection_pass2_ms: 0.0, measurement_ms: 0.0, snr_ms: 0.0,
677 statistics_ms: 0.0, total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
678 },
679 })
680 };
681
682 if detected.is_empty() {
683 return make_zero_result(0);
684 }
685
686 let t = std::time::Instant::now();
688 let stars_detected = detected.len();
689
690 let effective_cap = if self.config.measure_cap == 0 {
694 detected.len()
695 } else {
696 self.config.measure_cap
697 };
698
699 let to_measure: Vec<detection::DetectedStar> = if detected.len() <= effective_cap {
700 detected.clone()
701 } else {
702 debug_assert!(
703 detected.windows(2).all(|w| w[0].flux >= w[1].flux),
704 "detected stars must be sorted by flux descending"
705 );
706 const GRID_N: usize = 4;
707 let cell_w = width as f32 / GRID_N as f32;
708 let cell_h = height as f32 / GRID_N as f32;
709 let mut buckets: Vec<Vec<&detection::DetectedStar>> =
710 vec![Vec::new(); GRID_N * GRID_N];
711
712 for star in &detected {
713 let gx = ((star.x / cell_w) as usize).min(GRID_N - 1);
714 let gy = ((star.y / cell_h) as usize).min(GRID_N - 1);
715 buckets[gy * GRID_N + gx].push(star);
716 }
717
718 let mut selected: Vec<detection::DetectedStar> = Vec::with_capacity(effective_cap);
719 let mut idx = vec![0usize; GRID_N * GRID_N];
720 loop {
721 let mut added_any = false;
722 for cell in 0..(GRID_N * GRID_N) {
723 if selected.len() >= effective_cap { break; }
724 if idx[cell] < buckets[cell].len() {
725 selected.push(buckets[cell][idx[cell]].clone());
726 idx[cell] += 1;
727 added_any = true;
728 }
729 }
730 if !added_any || selected.len() >= effective_cap { break; }
731 }
732 selected
733 };
734
735 let mut measured = metrics::measure_stars(
736 &lum, width, height, &to_measure,
737 bg_result.background, bg_map_ref,
738 None, field_beta, self.config.fit_max_iter,
740 self.config.fit_tolerance,
741 self.config.fit_max_rejects,
742 if field_fwhm > 1.0 { Some(field_fwhm) } else { None }, );
744 let measurement_ms = t.elapsed().as_secs_f64() * 1000.0;
745 let stars_measured = measured.len();
746 let moffat_count = measured.iter()
747 .filter(|s| matches!(s.fit_method, FitMethod::FreeMoffat | FitMethod::FixedMoffat))
748 .count();
749 let gaussian_count = measured.iter()
750 .filter(|s| matches!(s.fit_method, FitMethod::Gaussian))
751 .count();
752
753 if measured.is_empty() {
754 return make_zero_result(stars_detected);
755 }
756
757 let t = std::time::Instant::now();
759
760 const FWHM_ECC_MAX: f32 = 0.8;
767 let fwhm_filtered: Vec<&metrics::MeasuredStar> = if possibly_trailed {
768 measured.iter().collect()
769 } else {
770 let round: Vec<&metrics::MeasuredStar> = measured.iter()
771 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
772 .collect();
773 if round.len() >= 3 { round } else { measured.iter().collect() }
774 };
775 let (fwhm_vals, hfr_vals, shape_weights) = (
776 fwhm_filtered.iter().map(|s| s.fwhm).collect::<Vec<f32>>(),
777 fwhm_filtered.iter().map(|s| s.hfr).collect::<Vec<f32>>(),
778 fwhm_filtered.iter().map(|s| 1.0 / (1.0 + s.fit_residual)).collect::<Vec<f32>>(),
779 );
780 let median_fwhm = sigma_clipped_weighted_median(&fwhm_vals, &shape_weights);
781
782 let ecc_use_all = possibly_trailed;
786 let ecc_filtered: Vec<&metrics::MeasuredStar> = if ecc_use_all {
787 measured.iter().collect()
788 } else {
789 let filtered: Vec<&metrics::MeasuredStar> = measured.iter()
790 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
791 .collect();
792 if filtered.len() >= 3 { filtered } else { measured.iter().collect() }
793 };
794 let ecc_vals: Vec<f32> = ecc_filtered.iter().map(|s| s.eccentricity).collect();
795 let ecc_weights: Vec<f32> = ecc_filtered.iter()
796 .map(|s| 1.0 / (1.0 + s.fit_residual))
797 .collect();
798
799 let statistics_ms_before_snr = t.elapsed().as_secs_f64() * 1000.0;
800
801 let t = std::time::Instant::now();
802 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
803 let snr_ms = t.elapsed().as_secs_f64() * 1000.0;
804
805 let t = std::time::Instant::now();
806 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
807
808 let median_eccentricity = sigma_clipped_weighted_median(&ecc_vals, &ecc_weights);
809 let median_snr = find_median(&mut snr_vals);
810 let median_hfr = sigma_clipped_weighted_median(&hfr_vals, &shape_weights);
811 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
812
813 let median_beta = if let Some(fb) = field_beta {
815 Some(fb as f32)
816 } else {
817 let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
818 if beta_vals.is_empty() { None } else { Some(find_median(&mut beta_vals)) }
819 };
820
821 let plate_scale = match (self.config.focal_length_mm, self.config.pixel_size_um) {
822 (Some(fl), Some(ps)) if fl > 0.0 && ps > 0.0 => {
823 Some((ps / fl * 206.265) as f32)
824 }
825 _ => None,
826 };
827
828 let median_fwhm_arcsec = plate_scale.map(|s| median_fwhm * s);
829 let median_hfr_arcsec = plate_scale.map(|s| median_hfr * s);
830
831 measured.truncate(self.config.max_stars);
833
834 let stars: Vec<StarMetrics> = measured
835 .into_iter()
836 .map(|m| StarMetrics {
837 x: m.x, y: m.y, peak: m.peak, flux: m.flux,
838 fwhm_x: m.fwhm_x, fwhm_y: m.fwhm_y, fwhm: m.fwhm,
839 eccentricity: m.eccentricity, snr: m.snr, hfr: m.hfr,
840 theta: m.theta, beta: m.beta, fit_method: m.fit_method,
841 fit_residual: m.fit_residual,
842 fwhm_arcsec: plate_scale.map(|s| m.fwhm * s),
843 hfr_arcsec: plate_scale.map(|s| m.hfr * s),
844 })
845 .collect();
846 let statistics_ms = statistics_ms_before_snr + t.elapsed().as_secs_f64() * 1000.0;
847 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
848
849 Ok(AnalysisResult {
850 width, height, source_channels: channels,
851 background: bg_result.background, noise: bg_result.noise,
852 detection_threshold, stars_detected, stars,
853 median_fwhm, median_eccentricity, median_snr, median_hfr,
854 snr_weight, psf_signal, frame_snr,
855 trail_r_squared, possibly_trailed,
856 measured_fwhm_kernel: final_fwhm,
857 median_beta,
858 pass1_detections, calibrated_fwhm: field_fwhm,
859 stars_measured, moffat_count, gaussian_count,
860 plate_scale, median_fwhm_arcsec, median_hfr_arcsec,
861 stage_timing: StageTiming {
862 background_ms, detection_pass1_ms, calibration_ms,
863 detection_pass2_ms, measurement_ms, snr_ms,
864 statistics_ms, total_ms,
865 },
866 })
867 }
868}
869
870impl Default for ImageAnalyzer {
871 fn default() -> Self {
872 Self::new()
873 }
874}
875
876pub fn sigma_clipped_median(values: &[f32]) -> f32 {
885 if values.is_empty() {
886 return 0.0;
887 }
888 let mut v: Vec<f32> = values.to_vec();
889 for _ in 0..2 {
890 if v.len() < 3 {
891 break;
892 }
893 let med = find_median(&mut v);
894 let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
895 let mad = find_median(&mut abs_devs);
896 let sigma_mad = 1.4826 * mad;
897 if sigma_mad < 1e-10 {
898 break; }
900 let clip = 3.0 * sigma_mad;
901 v.retain(|&x| (x - med).abs() <= clip);
902 }
903 if v.is_empty() {
904 return find_median(&mut values.to_vec());
905 }
906 find_median(&mut v)
907}
908
909pub fn weighted_median(values: &[f32], weights: &[f32]) -> f32 {
913 if values.is_empty() || values.len() != weights.len() {
914 return 0.0;
915 }
916 let mut pairs: Vec<(f32, f32)> = values.iter().copied()
917 .zip(weights.iter().copied())
918 .filter(|(_, w)| *w > 0.0)
919 .collect();
920 if pairs.is_empty() {
921 return 0.0;
922 }
923 pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
924 let total: f32 = pairs.iter().map(|(_, w)| w).sum();
925 if total <= 0.0 {
926 return 0.0;
927 }
928 let half = total * 0.5;
929 let mut cumulative = 0.0_f32;
930 for &(val, w) in &pairs {
931 cumulative += w;
932 if cumulative >= half {
933 return val;
934 }
935 }
936 pairs.last().unwrap().0
937}
938
939pub fn sigma_clipped_weighted_median(values: &[f32], weights: &[f32]) -> f32 {
944 if values.is_empty() || values.len() != weights.len() {
945 return 0.0;
946 }
947 let mut v: Vec<f32> = values.to_vec();
948 let mut w: Vec<f32> = weights.to_vec();
949 for _ in 0..2 {
950 if v.len() < 3 {
951 break;
952 }
953 let med = weighted_median(&v, &w);
954 let abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
955 let mut sorted_devs = abs_devs.clone();
957 sorted_devs.sort_by(|a, b| a.total_cmp(b));
958 let mad = sorted_devs[sorted_devs.len() / 2];
959 let sigma_mad = 1.4826 * mad;
960 if sigma_mad < 1e-10 {
961 break;
962 }
963 let clip = 3.0 * sigma_mad;
964 let mut new_v = Vec::with_capacity(v.len());
965 let mut new_w = Vec::with_capacity(w.len());
966 for (val, wt) in v.iter().zip(w.iter()) {
967 if (*val - med).abs() <= clip {
968 new_v.push(*val);
969 new_w.push(*wt);
970 }
971 }
972 v = new_v;
973 w = new_w;
974 }
975 if v.is_empty() {
976 return weighted_median(values, weights);
977 }
978 weighted_median(&v, &w)
979}
980
981pub fn estimate_fwhm_from_stars(
985 lum: &[f32],
986 width: usize,
987 height: usize,
988 stars: &[detection::DetectedStar],
989 background: f32,
990 bg_map: Option<&[f32]>,
991) -> f32 {
992 let scan_n = stars.len().min(50);
996 if scan_n < 3 {
997 return 0.0;
998 }
999
1000 let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
1001 .iter()
1002 .filter(|s| s.eccentricity <= 0.7)
1003 .take(20)
1004 .collect();
1005 if round_stars.len() < 3 {
1006 return 0.0;
1007 }
1008
1009 let mut fwhm_vals = Vec::with_capacity(round_stars.len());
1010 for star in &round_stars {
1011 let stamp_radius = 30_usize; let cx = star.x.round() as i32;
1013 let cy = star.y.round() as i32;
1014 let sr = stamp_radius as i32;
1015 if cx - sr <= 0 || cy - sr <= 0
1016 || cx + sr >= width as i32 - 1
1017 || cy + sr >= height as i32 - 1
1018 {
1019 continue;
1020 }
1021 let x0 = (cx - sr) as usize;
1022 let y0 = (cy - sr) as usize;
1023 let x1 = (cx + sr) as usize;
1024 let y1 = (cy + sr) as usize;
1025 let stamp_w = x1 - x0 + 1;
1026 let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
1027 for sy in y0..=y1 {
1028 for sx in x0..=x1 {
1029 let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
1030 stamp.push(lum[sy * width + sx] - bg);
1031 }
1032 }
1033 let rel_cx = star.x - x0 as f32;
1034 let rel_cy = star.y - y0 as f32;
1035 let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
1036 let fwhm = sigma * 2.3548;
1037 if fwhm > 1.0 && fwhm < 20.0 {
1038 fwhm_vals.push(fwhm);
1039 }
1040 }
1041
1042 if fwhm_vals.len() < 3 {
1043 return 0.0;
1044 }
1045 find_median(&mut fwhm_vals)
1046}
1047
1048pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
1053 use rayon::prelude::*;
1054
1055 let plane_size = width * height;
1056 let r = &data[..plane_size];
1057 let g = &data[plane_size..2 * plane_size];
1058 let b = &data[2 * plane_size..3 * plane_size];
1059
1060 let mut lum = vec![0.0_f32; plane_size];
1061 const CHUNK: usize = 8192;
1062 lum.par_chunks_mut(CHUNK)
1063 .enumerate()
1064 .for_each(|(ci, chunk)| {
1065 let off = ci * CHUNK;
1066 for (i, dst) in chunk.iter_mut().enumerate() {
1067 let idx = off + i;
1068 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
1069 }
1070 });
1071 lum
1072}
1073
1074#[cfg(feature = "debug-pipeline")]
1080pub fn prepare_luminance(
1081 meta: &crate::types::ImageMetadata,
1082 pixels: &crate::types::PixelData,
1083 apply_debayer: bool,
1084) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
1085 use crate::processing::color::u16_to_f32;
1086 use crate::processing::debayer;
1087
1088 let f32_data = match pixels {
1089 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
1090 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
1091 };
1092
1093 let mut data = f32_data.into_owned();
1094 let width = meta.width;
1095 let height = meta.height;
1096 let channels = meta.channels;
1097
1098 if apply_debayer
1101 && meta.bayer_pattern != BayerPattern::None
1102 && channels == 1
1103 {
1104 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
1105 }
1106 let green_mask: Option<Vec<bool>> = None;
1107
1108 let lum = if channels == 3 {
1109 extract_luminance(&data, width, height)
1110 } else {
1111 data[..width * height].to_vec()
1112 };
1113
1114 (lum, width, height, channels, green_mask)
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120
1121 #[test]
1122 fn test_weighted_median_equal_weights() {
1123 let vals = [1.0_f32, 3.0, 5.0, 7.0, 9.0];
1125 let wts = [1.0_f32; 5];
1126 let wm = weighted_median(&vals, &wts);
1127 assert!((wm - 5.0).abs() < 0.01, "Equal-weight median should be 5, got {}", wm);
1128 }
1129
1130 #[test]
1131 fn test_weighted_median_skewed_weights() {
1132 let vals = [1.0_f32, 10.0];
1134 let wts = [9.0_f32, 1.0]; let wm = weighted_median(&vals, &wts);
1136 assert!((wm - 1.0).abs() < 0.01, "Skewed-weight median should be 1, got {}", wm);
1137 }
1138
1139 #[test]
1140 fn test_weighted_median_empty() {
1141 let wm = weighted_median(&[], &[]);
1142 assert_eq!(wm, 0.0);
1143 }
1144
1145 #[test]
1146 fn test_weighted_median_single() {
1147 let wm = weighted_median(&[42.0], &[1.0]);
1148 assert!((wm - 42.0).abs() < 0.01);
1149 }
1150
1151 #[test]
1152 fn test_sigma_clipped_weighted_median_basic() {
1153 let vals = [3.0_f32, 3.1, 3.0, 3.2, 3.0, 100.0]; let wts = [1.0_f32; 6];
1156 let scwm = sigma_clipped_weighted_median(&vals, &wts);
1157 assert!(scwm < 4.0, "Outlier should be clipped, got {}", scwm);
1158 }
1159}