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 StageTiming {
99 pub background_ms: f64,
100 pub detection_pass1_ms: f64,
101 pub calibration_ms: f64,
102 pub detection_pass2_ms: f64,
103 pub measurement_ms: f64,
104 pub snr_ms: f64,
105 pub statistics_ms: f64,
106 pub total_ms: f64,
107}
108
109pub struct AnalysisResult {
111 pub width: usize,
113 pub height: usize,
115 pub source_channels: usize,
117 pub background: f32,
119 pub noise: f32,
121 pub detection_threshold: f32,
123 pub stars_detected: usize,
125 pub stars: Vec<StarMetrics>,
127 pub median_fwhm: f32,
129 pub median_eccentricity: f32,
131 pub median_snr: f32,
133 pub median_hfr: f32,
135 pub snr_weight: f32,
137 pub psf_signal: f32,
139 pub frame_snr: f32,
142 pub trail_r_squared: f32,
147 pub possibly_trailed: bool,
152 pub measured_fwhm_kernel: f32,
156 pub median_beta: Option<f32>,
159 pub stage_timing: StageTiming,
161}
162
163pub struct AnalysisConfig {
165 detection_sigma: f32,
166 min_star_area: usize,
167 max_star_area: usize,
168 saturation_fraction: f32,
169 max_stars: usize,
170 apply_debayer: bool,
171 trail_r_squared_threshold: f32,
172 noise_layers: usize,
174 measure_cap: usize,
176 fit_max_iter: usize,
178 fit_tolerance: f64,
180 fit_max_rejects: usize,
182}
183
184pub struct ImageAnalyzer {
186 config: AnalysisConfig,
187 thread_pool: Option<Arc<rayon::ThreadPool>>,
188}
189
190impl ImageAnalyzer {
191 pub fn new() -> Self {
192 ImageAnalyzer {
193 config: AnalysisConfig {
194 detection_sigma: 5.0,
195 min_star_area: 5,
196 max_star_area: 2000,
197 saturation_fraction: 0.95,
198 max_stars: 200,
199 apply_debayer: true,
200 trail_r_squared_threshold: 0.5,
201 noise_layers: 0,
202 measure_cap: 500,
203 fit_max_iter: 25,
204 fit_tolerance: 1e-4,
205 fit_max_rejects: 5,
206 },
207 thread_pool: None,
208 }
209 }
210
211 pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
213 self.config.detection_sigma = sigma.max(1.0);
214 self
215 }
216
217 pub fn with_min_star_area(mut self, area: usize) -> Self {
219 self.config.min_star_area = area.max(1);
220 self
221 }
222
223 pub fn with_max_star_area(mut self, area: usize) -> Self {
225 self.config.max_star_area = area;
226 self
227 }
228
229 pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
231 self.config.saturation_fraction = frac.clamp(0.5, 1.0);
232 self
233 }
234
235 pub fn with_max_stars(mut self, n: usize) -> Self {
237 self.config.max_stars = n.max(1);
238 self
239 }
240
241 pub fn without_debayer(mut self) -> Self {
243 self.config.apply_debayer = false;
244 self
245 }
246
247 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
251 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
252 self
253 }
254
255 pub fn with_mrs_layers(mut self, layers: usize) -> Self {
260 self.config.noise_layers = layers;
261 self
262 }
263
264 pub fn with_measure_cap(mut self, n: usize) -> Self {
268 self.config.measure_cap = n;
269 self
270 }
271
272 pub fn with_fit_max_iter(mut self, n: usize) -> Self {
275 self.config.fit_max_iter = n.max(1);
276 self
277 }
278
279 pub fn with_fit_tolerance(mut self, tol: f64) -> Self {
282 self.config.fit_tolerance = tol;
283 self
284 }
285
286 pub fn with_fit_max_rejects(mut self, n: usize) -> Self {
288 self.config.fit_max_rejects = n.max(1);
289 self
290 }
291
292 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
294 self.thread_pool = Some(pool);
295 self
296 }
297
298 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
300 let path = path.as_ref();
301 match &self.thread_pool {
302 Some(pool) => pool.install(|| self.analyze_impl(path)),
303 None => self.analyze_impl(path),
304 }
305 }
306
307 pub fn analyze_data(
314 &self,
315 data: &[f32],
316 width: usize,
317 height: usize,
318 channels: usize,
319 ) -> Result<AnalysisResult> {
320 match &self.thread_pool {
321 Some(pool) => pool.install(|| {
322 self.run_analysis(data, width, height, channels)
323 }),
324 None => self.run_analysis(data, width, height, channels),
325 }
326 }
327
328 pub fn analyze_raw(
333 &self,
334 meta: &ImageMetadata,
335 pixels: &PixelData,
336 ) -> Result<AnalysisResult> {
337 match &self.thread_pool {
338 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
339 None => self.analyze_raw_impl(meta, pixels),
340 }
341 }
342
343 pub fn analyze_batch<P, F>(
349 &self,
350 paths: &[P],
351 concurrency: usize,
352 progress: F,
353 ) -> Vec<(std::path::PathBuf, Result<AnalysisResult>)>
354 where
355 P: AsRef<std::path::Path> + Sync,
356 F: Fn(usize, usize, &std::path::Path) + Send + Sync,
357 {
358 use std::sync::atomic::{AtomicUsize, Ordering};
359 use rayon::prelude::*;
360
361 let total = paths.len();
362 let completed = AtomicUsize::new(0);
363 let concurrency = concurrency.max(1);
364
365 let do_batch = || {
366 let mut results = Vec::with_capacity(total);
367 for chunk in paths.chunks(concurrency) {
368 let chunk_results: Vec<_> = chunk
369 .into_par_iter()
370 .map(|p| {
371 let path = p.as_ref();
372 let result = self.analyze_impl(path);
373 let n = completed.fetch_add(1, Ordering::Relaxed) + 1;
374 progress(n, total, path);
375 (path.to_path_buf(), result)
376 })
377 .collect();
378 results.extend(chunk_results);
379 }
380 results
381 };
382
383 match &self.thread_pool {
384 Some(pool) => pool.install(do_batch),
385 None => do_batch(),
386 }
387 }
388
389 fn analyze_raw_impl(
390 &self,
391 meta: &ImageMetadata,
392 pixels: &PixelData,
393 ) -> Result<AnalysisResult> {
394 let f32_data = match pixels {
395 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
396 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
397 };
398
399 let mut data = f32_data.into_owned();
400 let width = meta.width;
401 let height = meta.height;
402 let channels = meta.channels;
403
404 if self.config.apply_debayer
407 && meta.bayer_pattern != BayerPattern::None
408 && channels == 1
409 {
410 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
411 }
412
413 self.run_analysis(&data, width, height, channels)
414 }
415
416 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
417 let (meta, pixel_data) =
418 formats::read_image(path).context("Failed to read image for analysis")?;
419
420 let f32_data = match pixel_data {
422 PixelData::Float32(d) => d,
423 PixelData::Uint16(d) => u16_to_f32(&d),
424 };
425
426 let mut data = f32_data;
427 let width = meta.width;
428 let height = meta.height;
429 let channels = meta.channels;
430
431 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 run_analysis(
443 &self,
444 data: &[f32],
445 width: usize,
446 height: usize,
447 channels: usize,
448 ) -> Result<AnalysisResult> {
449 let pipeline_start = std::time::Instant::now();
450
451 let lum = if channels == 3 {
453 extract_luminance(data, width, height)
454 } else {
455 data[..width * height].to_vec()
456 };
457
458 let det_params = DetectionParams {
459 detection_sigma: self.config.detection_sigma,
460 min_star_area: self.config.min_star_area,
461 max_star_area: self.config.max_star_area,
462 saturation_limit: self.config.saturation_fraction * 65535.0,
463 };
464
465 let t = std::time::Instant::now();
467 let cell_size = background::auto_cell_size(width, height);
468 let mut bg_result = background::estimate_background_mesh(&lum, width, height, cell_size);
469 if self.config.noise_layers > 0 {
470 bg_result.noise = background::estimate_noise_mrs(
472 &lum, width, height, self.config.noise_layers.max(1),
473 ).max(0.001);
474 }
475 let background_ms = t.elapsed().as_secs_f64() * 1000.0;
477
478 let t = std::time::Instant::now();
480 let initial_fwhm = 3.0_f32;
481 let pass1_stars = {
482 let bg_map = bg_result.background_map.as_deref();
483 let noise_map = bg_result.noise_map.as_deref();
484 detection::detect_stars(
485 &lum, width, height,
486 bg_result.background, bg_result.noise,
487 bg_map, noise_map, &det_params, initial_fwhm,
488 None,
489 )
490 };
491 let detection_pass1_ms = t.elapsed().as_secs_f64() * 1000.0;
492
493 let t = std::time::Instant::now();
495 let calibration_stars: Vec<&detection::DetectedStar> = pass1_stars
496 .iter()
497 .filter(|s| s.eccentricity < 0.5 && s.area >= 5)
498 .take(100)
499 .collect();
500
501 let field_beta: Option<f64>;
503 let field_fwhm: f32;
504 if calibration_stars.len() >= 3 {
505 let cal_owned: Vec<detection::DetectedStar> = calibration_stars
506 .iter()
507 .map(|s| detection::DetectedStar {
508 x: s.x, y: s.y, peak: s.peak, flux: s.flux,
509 area: s.area, theta: s.theta, eccentricity: s.eccentricity,
510 })
511 .collect();
512 let cal_measured = metrics::measure_stars(
513 &lum, width, height, &cal_owned,
514 bg_result.background,
515 bg_result.background_map.as_deref(),
516 None, None, 50, 1e-6, 5, None, false, );
522
523 let mut beta_vals: Vec<f32> = cal_measured.iter().filter_map(|s| s.beta).collect();
524 let mut fwhm_vals: Vec<f32> = cal_measured.iter().map(|s| s.fwhm).collect();
525
526 if beta_vals.len() >= 3 {
527 field_beta = Some(sigma_clipped_median(&beta_vals) as f64);
528 } else if !beta_vals.is_empty() {
529 field_beta = Some(find_median(&mut beta_vals) as f64);
530 } else {
531 field_beta = None;
532 }
533
534 if fwhm_vals.len() >= 3 {
535 field_fwhm = sigma_clipped_median(&fwhm_vals);
536 } else if !fwhm_vals.is_empty() {
537 field_fwhm = find_median(&mut fwhm_vals);
538 } else {
539 field_fwhm = estimate_fwhm_from_stars(
540 &lum, width, height, &pass1_stars,
541 bg_result.background, bg_result.background_map.as_deref(),
542 );
543 }
544 } else {
545 field_beta = None;
547 field_fwhm = estimate_fwhm_from_stars(
548 &lum, width, height, &pass1_stars,
549 bg_result.background, bg_result.background_map.as_deref(),
550 );
551 }
552
553 let calibration_ms = t.elapsed().as_secs_f64() * 1000.0;
554
555 let t = std::time::Instant::now();
562 let clamped_fwhm = field_fwhm.max(2.0);
566 let final_fwhm = if clamped_fwhm > 1.0
567 && ((clamped_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
568 {
569 clamped_fwhm.min(initial_fwhm * 2.0)
570 } else {
571 initial_fwhm
572 };
573
574 let detected = {
575 let bg_map = bg_result.background_map.as_deref();
576 let noise_map = bg_result.noise_map.as_deref();
577 detection::detect_stars(
578 &lum, width, height,
579 bg_result.background, bg_result.noise,
580 bg_map, noise_map, &det_params, final_fwhm,
581 Some(clamped_fwhm),
582 )
583 };
584 let detection_pass2_ms = t.elapsed().as_secs_f64() * 1000.0;
585
586 let bg_map_ref = bg_result.background_map.as_deref();
587 let detection_threshold = self.config.detection_sigma * bg_result.noise;
588
589 let (trail_r_squared, possibly_trailed) = if detected.len() >= 20 {
593 let n = detected.len();
594 let (sum_cos, sum_sin) =
595 detected.iter().fold((0.0f64, 0.0f64), |(sc, ss), s| {
596 let a = 2.0 * s.theta as f64;
597 (sc + a.cos(), ss + a.sin())
598 });
599 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
600 let p = (-(n as f64) * r_sq).exp();
601 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
602 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
603 let median_ecc = if eccs.len() % 2 == 1 {
604 eccs[eccs.len() / 2]
605 } else {
606 (eccs[eccs.len() / 2 - 1] + eccs[eccs.len() / 2]) * 0.5
607 };
608 let threshold = self.config.trail_r_squared_threshold as f64;
609 let trailed = (r_sq > threshold && p < 0.01) || (r_sq > 0.05 && median_ecc > 0.6 && p < 0.05); (r_sq as f32, trailed)
612 } else {
613 (0.0, false)
614 };
615
616 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
617 let frame_snr = if bg_result.noise > 0.0 { bg_result.background / bg_result.noise } else { 0.0 };
618
619 let make_zero_result = |stars_detected: usize| {
620 Ok(AnalysisResult {
621 width, height, source_channels: channels,
622 background: bg_result.background, noise: bg_result.noise,
623 detection_threshold, stars_detected,
624 stars: Vec::new(),
625 median_fwhm: 0.0, median_eccentricity: 0.0,
626 median_snr: 0.0, median_hfr: 0.0,
627 snr_weight, psf_signal: 0.0, frame_snr,
628 trail_r_squared, possibly_trailed,
629 measured_fwhm_kernel: final_fwhm,
630 median_beta: field_beta.map(|b| b as f32),
631 stage_timing: StageTiming {
632 background_ms: 0.0, detection_pass1_ms: 0.0, calibration_ms: 0.0,
633 detection_pass2_ms: 0.0, measurement_ms: 0.0, snr_ms: 0.0,
634 statistics_ms: 0.0, total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
635 },
636 })
637 };
638
639 if detected.is_empty() {
640 return make_zero_result(0);
641 }
642
643 let t = std::time::Instant::now();
645 let stars_detected = detected.len();
646
647 let effective_cap = if self.config.measure_cap == 0 {
651 detected.len()
652 } else {
653 self.config.measure_cap
654 };
655
656 let to_measure: Vec<detection::DetectedStar> = if detected.len() <= effective_cap {
657 detected.clone()
658 } else {
659 debug_assert!(
660 detected.windows(2).all(|w| w[0].flux >= w[1].flux),
661 "detected stars must be sorted by flux descending"
662 );
663 const GRID_N: usize = 4;
664 let cell_w = width as f32 / GRID_N as f32;
665 let cell_h = height as f32 / GRID_N as f32;
666 let mut buckets: Vec<Vec<&detection::DetectedStar>> =
667 vec![Vec::new(); GRID_N * GRID_N];
668
669 for star in &detected {
670 let gx = ((star.x / cell_w) as usize).min(GRID_N - 1);
671 let gy = ((star.y / cell_h) as usize).min(GRID_N - 1);
672 buckets[gy * GRID_N + gx].push(star);
673 }
674
675 let mut selected: Vec<detection::DetectedStar> = Vec::with_capacity(effective_cap);
676 let mut idx = vec![0usize; GRID_N * GRID_N];
677 loop {
678 let mut added_any = false;
679 for cell in 0..(GRID_N * GRID_N) {
680 if selected.len() >= effective_cap { break; }
681 if idx[cell] < buckets[cell].len() {
682 selected.push(buckets[cell][idx[cell]].clone());
683 idx[cell] += 1;
684 added_any = true;
685 }
686 }
687 if !added_any || selected.len() >= effective_cap { break; }
688 }
689 selected
690 };
691
692 let mut measured = metrics::measure_stars(
693 &lum, width, height, &to_measure,
694 bg_result.background, bg_map_ref,
695 None, field_beta, self.config.fit_max_iter,
697 self.config.fit_tolerance,
698 self.config.fit_max_rejects,
699 Some(field_fwhm), possibly_trailed, );
702 let measurement_ms = t.elapsed().as_secs_f64() * 1000.0;
703
704 if measured.is_empty() {
705 return make_zero_result(stars_detected);
706 }
707
708 let t = std::time::Instant::now();
710
711 const FWHM_ECC_MAX: f32 = 0.8;
718 let fwhm_filtered: Vec<&metrics::MeasuredStar> = if possibly_trailed {
719 measured.iter().collect()
720 } else {
721 let round: Vec<&metrics::MeasuredStar> = measured.iter()
722 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
723 .collect();
724 if round.len() >= 3 { round } else { measured.iter().collect() }
725 };
726 let (fwhm_vals, hfr_vals, shape_weights) = (
727 fwhm_filtered.iter().map(|s| s.fwhm).collect::<Vec<f32>>(),
728 fwhm_filtered.iter().map(|s| s.hfr).collect::<Vec<f32>>(),
729 fwhm_filtered.iter().map(|s| 1.0 / (1.0 + s.fit_residual)).collect::<Vec<f32>>(),
730 );
731 let median_fwhm = sigma_clipped_weighted_median(&fwhm_vals, &shape_weights);
732
733 let ecc_use_all = possibly_trailed;
737 let ecc_filtered: Vec<&metrics::MeasuredStar> = if ecc_use_all {
738 measured.iter().collect()
739 } else {
740 let filtered: Vec<&metrics::MeasuredStar> = measured.iter()
741 .filter(|s| s.eccentricity <= FWHM_ECC_MAX)
742 .collect();
743 if filtered.len() >= 3 { filtered } else { measured.iter().collect() }
744 };
745 let ecc_vals: Vec<f32> = ecc_filtered.iter().map(|s| s.eccentricity).collect();
746 let ecc_weights: Vec<f32> = ecc_filtered.iter()
747 .map(|s| 1.0 / (1.0 + s.fit_residual))
748 .collect();
749
750 let statistics_ms_before_snr = t.elapsed().as_secs_f64() * 1000.0;
751
752 let t = std::time::Instant::now();
753 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
754 let snr_ms = t.elapsed().as_secs_f64() * 1000.0;
755
756 let t = std::time::Instant::now();
757 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
758
759 let median_eccentricity = sigma_clipped_weighted_median(&ecc_vals, &ecc_weights);
760 let median_snr = find_median(&mut snr_vals);
761 let median_hfr = sigma_clipped_weighted_median(&hfr_vals, &shape_weights);
762 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
763
764 let median_beta = if let Some(fb) = field_beta {
766 Some(fb as f32)
767 } else {
768 let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
769 if beta_vals.is_empty() { None } else { Some(find_median(&mut beta_vals)) }
770 };
771
772 measured.truncate(self.config.max_stars);
774
775 let stars: Vec<StarMetrics> = measured
776 .into_iter()
777 .map(|m| StarMetrics {
778 x: m.x, y: m.y, peak: m.peak, flux: m.flux,
779 fwhm_x: m.fwhm_x, fwhm_y: m.fwhm_y, fwhm: m.fwhm,
780 eccentricity: m.eccentricity, snr: m.snr, hfr: m.hfr,
781 theta: m.theta, beta: m.beta, fit_method: m.fit_method,
782 fit_residual: m.fit_residual,
783 })
784 .collect();
785 let statistics_ms = statistics_ms_before_snr + t.elapsed().as_secs_f64() * 1000.0;
786 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
787
788 Ok(AnalysisResult {
789 width, height, source_channels: channels,
790 background: bg_result.background, noise: bg_result.noise,
791 detection_threshold, stars_detected, stars,
792 median_fwhm, median_eccentricity, median_snr, median_hfr,
793 snr_weight, psf_signal, frame_snr,
794 trail_r_squared, possibly_trailed,
795 measured_fwhm_kernel: final_fwhm,
796 median_beta,
797 stage_timing: StageTiming {
798 background_ms, detection_pass1_ms, calibration_ms,
799 detection_pass2_ms, measurement_ms, snr_ms,
800 statistics_ms, total_ms,
801 },
802 })
803 }
804}
805
806impl Default for ImageAnalyzer {
807 fn default() -> Self {
808 Self::new()
809 }
810}
811
812pub fn sigma_clipped_median(values: &[f32]) -> f32 {
821 if values.is_empty() {
822 return 0.0;
823 }
824 let mut v: Vec<f32> = values.to_vec();
825 for _ in 0..2 {
826 if v.len() < 3 {
827 break;
828 }
829 let med = find_median(&mut v);
830 let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
831 let mad = find_median(&mut abs_devs);
832 let sigma_mad = 1.4826 * mad;
833 if sigma_mad < 1e-10 {
834 break; }
836 let clip = 3.0 * sigma_mad;
837 v.retain(|&x| (x - med).abs() <= clip);
838 }
839 if v.is_empty() {
840 return find_median(&mut values.to_vec());
841 }
842 find_median(&mut v)
843}
844
845pub fn weighted_median(values: &[f32], weights: &[f32]) -> f32 {
849 if values.is_empty() || values.len() != weights.len() {
850 return 0.0;
851 }
852 let mut pairs: Vec<(f32, f32)> = values.iter().copied()
853 .zip(weights.iter().copied())
854 .filter(|(_, w)| *w > 0.0)
855 .collect();
856 if pairs.is_empty() {
857 return 0.0;
858 }
859 pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
860 let total: f32 = pairs.iter().map(|(_, w)| w).sum();
861 if total <= 0.0 {
862 return 0.0;
863 }
864 let half = total * 0.5;
865 let mut cumulative = 0.0_f32;
866 for &(val, w) in &pairs {
867 cumulative += w;
868 if cumulative >= half {
869 return val;
870 }
871 }
872 pairs.last().unwrap().0
873}
874
875pub fn sigma_clipped_weighted_median(values: &[f32], weights: &[f32]) -> f32 {
880 if values.is_empty() || values.len() != weights.len() {
881 return 0.0;
882 }
883 let mut v: Vec<f32> = values.to_vec();
884 let mut w: Vec<f32> = weights.to_vec();
885 for _ in 0..2 {
886 if v.len() < 3 {
887 break;
888 }
889 let med = weighted_median(&v, &w);
890 let abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
891 let mut sorted_devs = abs_devs.clone();
893 sorted_devs.sort_by(|a, b| a.total_cmp(b));
894 let mad = sorted_devs[sorted_devs.len() / 2];
895 let sigma_mad = 1.4826 * mad;
896 if sigma_mad < 1e-10 {
897 break;
898 }
899 let clip = 3.0 * sigma_mad;
900 let mut new_v = Vec::with_capacity(v.len());
901 let mut new_w = Vec::with_capacity(w.len());
902 for (val, wt) in v.iter().zip(w.iter()) {
903 if (*val - med).abs() <= clip {
904 new_v.push(*val);
905 new_w.push(*wt);
906 }
907 }
908 v = new_v;
909 w = new_w;
910 }
911 if v.is_empty() {
912 return weighted_median(values, weights);
913 }
914 weighted_median(&v, &w)
915}
916
917pub fn estimate_fwhm_from_stars(
921 lum: &[f32],
922 width: usize,
923 height: usize,
924 stars: &[detection::DetectedStar],
925 background: f32,
926 bg_map: Option<&[f32]>,
927) -> f32 {
928 let scan_n = stars.len().min(50);
932 if scan_n < 3 {
933 return 0.0;
934 }
935
936 let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
937 .iter()
938 .filter(|s| s.eccentricity <= 0.7)
939 .take(20)
940 .collect();
941 if round_stars.len() < 3 {
942 return 0.0;
943 }
944
945 let mut fwhm_vals = Vec::with_capacity(round_stars.len());
946 for star in &round_stars {
947 let stamp_radius = 12_usize; let cx = star.x.round() as i32;
949 let cy = star.y.round() as i32;
950 let sr = stamp_radius as i32;
951 if cx - sr <= 0 || cy - sr <= 0
952 || cx + sr >= width as i32 - 1
953 || cy + sr >= height as i32 - 1
954 {
955 continue;
956 }
957 let x0 = (cx - sr) as usize;
958 let y0 = (cy - sr) as usize;
959 let x1 = (cx + sr) as usize;
960 let y1 = (cy + sr) as usize;
961 let stamp_w = x1 - x0 + 1;
962 let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
963 for sy in y0..=y1 {
964 for sx in x0..=x1 {
965 let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
966 stamp.push(lum[sy * width + sx] - bg);
967 }
968 }
969 let rel_cx = star.x - x0 as f32;
970 let rel_cy = star.y - y0 as f32;
971 let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
972 let fwhm = sigma * 2.3548;
973 if fwhm > 1.0 && fwhm < 20.0 {
974 fwhm_vals.push(fwhm);
975 }
976 }
977
978 if fwhm_vals.len() < 3 {
979 return 0.0;
980 }
981 find_median(&mut fwhm_vals)
982}
983
984pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
989 use rayon::prelude::*;
990
991 let plane_size = width * height;
992 let r = &data[..plane_size];
993 let g = &data[plane_size..2 * plane_size];
994 let b = &data[2 * plane_size..3 * plane_size];
995
996 let mut lum = vec![0.0_f32; plane_size];
997 const CHUNK: usize = 8192;
998 lum.par_chunks_mut(CHUNK)
999 .enumerate()
1000 .for_each(|(ci, chunk)| {
1001 let off = ci * CHUNK;
1002 for (i, dst) in chunk.iter_mut().enumerate() {
1003 let idx = off + i;
1004 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
1005 }
1006 });
1007 lum
1008}
1009
1010#[cfg(feature = "debug-pipeline")]
1016pub fn prepare_luminance(
1017 meta: &crate::types::ImageMetadata,
1018 pixels: &crate::types::PixelData,
1019 apply_debayer: bool,
1020) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
1021 use crate::processing::color::u16_to_f32;
1022 use crate::processing::debayer;
1023
1024 let f32_data = match pixels {
1025 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
1026 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
1027 };
1028
1029 let mut data = f32_data.into_owned();
1030 let width = meta.width;
1031 let height = meta.height;
1032 let channels = meta.channels;
1033
1034 if apply_debayer
1037 && meta.bayer_pattern != BayerPattern::None
1038 && channels == 1
1039 {
1040 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
1041 }
1042 let green_mask: Option<Vec<bool>> = None;
1043
1044 let lum = if channels == 3 {
1045 extract_luminance(&data, width, height)
1046 } else {
1047 data[..width * height].to_vec()
1048 };
1049
1050 (lum, width, height, channels, green_mask)
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::*;
1056
1057 #[test]
1058 fn test_weighted_median_equal_weights() {
1059 let vals = [1.0_f32, 3.0, 5.0, 7.0, 9.0];
1061 let wts = [1.0_f32; 5];
1062 let wm = weighted_median(&vals, &wts);
1063 assert!((wm - 5.0).abs() < 0.01, "Equal-weight median should be 5, got {}", wm);
1064 }
1065
1066 #[test]
1067 fn test_weighted_median_skewed_weights() {
1068 let vals = [1.0_f32, 10.0];
1070 let wts = [9.0_f32, 1.0]; let wm = weighted_median(&vals, &wts);
1072 assert!((wm - 1.0).abs() < 0.01, "Skewed-weight median should be 1, got {}", wm);
1073 }
1074
1075 #[test]
1076 fn test_weighted_median_empty() {
1077 let wm = weighted_median(&[], &[]);
1078 assert_eq!(wm, 0.0);
1079 }
1080
1081 #[test]
1082 fn test_weighted_median_single() {
1083 let wm = weighted_median(&[42.0], &[1.0]);
1084 assert!((wm - 42.0).abs() < 0.01);
1085 }
1086
1087 #[test]
1088 fn test_sigma_clipped_weighted_median_basic() {
1089 let vals = [3.0_f32, 3.1, 3.0, 3.2, 3.0, 100.0]; let wts = [1.0_f32; 6];
1092 let scwm = sigma_clipped_weighted_median(&vals, &wts);
1093 assert!(scwm < 4.0, "Outlier should be clipped, got {}", scwm);
1094 }
1095}