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
49pub struct StarMetrics {
51 pub x: f32,
53 pub y: f32,
55 pub peak: f32,
57 pub flux: f32,
59 pub fwhm_x: f32,
61 pub fwhm_y: f32,
63 pub fwhm: f32,
65 pub eccentricity: f32,
67 pub snr: f32,
69 pub hfr: f32,
71 pub theta: f32,
75 pub beta: Option<f32>,
77}
78
79pub struct AnalysisResult {
81 pub width: usize,
83 pub height: usize,
85 pub source_channels: usize,
87 pub background: f32,
89 pub noise: f32,
91 pub detection_threshold: f32,
93 pub stars_detected: usize,
95 pub stars: Vec<StarMetrics>,
97 pub median_fwhm: f32,
99 pub median_eccentricity: f32,
101 pub median_snr: f32,
103 pub median_hfr: f32,
105 pub snr_db: f32,
107 pub snr_weight: f32,
109 pub psf_signal: f32,
111 pub trail_r_squared: f32,
115 pub possibly_trailed: bool,
118 pub measured_fwhm_kernel: f32,
122 pub median_beta: Option<f32>,
125}
126
127pub struct AnalysisConfig {
129 detection_sigma: f32,
130 min_star_area: usize,
131 max_star_area: usize,
132 saturation_fraction: f32,
133 max_measure: Option<usize>,
134 max_stars: usize,
135 use_gaussian_fit: bool,
136 background_mesh_size: Option<usize>,
137 apply_debayer: bool,
138 trail_r_squared_threshold: f32,
139 use_moffat_fit: bool,
140 iterative_background: usize,
141 noise_layers: usize,
143 moffat_beta: Option<f32>,
145 max_distortion: Option<f32>,
147}
148
149pub struct ImageAnalyzer {
151 config: AnalysisConfig,
152 thread_pool: Option<Arc<rayon::ThreadPool>>,
153}
154
155impl ImageAnalyzer {
156 pub fn new() -> Self {
157 ImageAnalyzer {
158 config: AnalysisConfig {
159 detection_sigma: 5.0,
160 min_star_area: 5,
161 max_star_area: 2000,
162 saturation_fraction: 0.95,
163 max_measure: None,
164 max_stars: 200,
165 use_gaussian_fit: true,
166 background_mesh_size: None,
167 apply_debayer: true,
168 trail_r_squared_threshold: 0.5,
169 use_moffat_fit: true,
170 iterative_background: 1,
171 noise_layers: 0,
172 moffat_beta: None,
173 max_distortion: None,
174 },
175 thread_pool: None,
176 }
177 }
178
179 pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
181 self.config.detection_sigma = sigma.max(1.0);
182 self
183 }
184
185 pub fn with_min_star_area(mut self, area: usize) -> Self {
187 self.config.min_star_area = area.max(1);
188 self
189 }
190
191 pub fn with_max_star_area(mut self, area: usize) -> Self {
193 self.config.max_star_area = area;
194 self
195 }
196
197 pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
199 self.config.saturation_fraction = frac.clamp(0.5, 1.0);
200 self
201 }
202
203 pub fn with_max_measure(mut self, n: usize) -> Self {
208 self.config.max_measure = Some(n.max(100));
209 self
210 }
211
212 pub fn with_max_stars(mut self, n: usize) -> Self {
214 let n = n.max(1);
215 self.config.max_stars = match self.config.max_measure {
217 Some(mm) => n.min(mm),
218 None => n,
219 };
220 self
221 }
222
223 pub fn without_gaussian_fit(mut self) -> Self {
225 self.config.use_gaussian_fit = false;
226 self
227 }
228
229 pub fn with_background_mesh(mut self, cell_size: usize) -> Self {
231 self.config.background_mesh_size = Some(cell_size.max(16));
232 self
233 }
234
235 pub fn without_debayer(mut self) -> Self {
237 self.config.apply_debayer = false;
238 self
239 }
240
241 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
245 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
246 self
247 }
248
249 pub fn with_iterative_background(mut self, iterations: usize) -> Self {
254 self.config.iterative_background = iterations.max(1);
255 self
256 }
257
258 pub fn with_mrs_noise(mut self, layers: usize) -> Self {
263 self.config.noise_layers = layers;
264 self
265 }
266
267 pub fn with_moffat_beta(mut self, beta: f32) -> Self {
272 self.config.moffat_beta = Some(beta);
273 self
274 }
275
276 pub fn with_max_distortion(mut self, ecc: f32) -> Self {
280 self.config.max_distortion = Some(ecc.clamp(0.0, 1.0));
281 self
282 }
283
284 pub fn with_moffat_fit(mut self) -> Self {
288 self.config.use_moffat_fit = true;
289 self
290 }
291
292 pub fn without_moffat_fit(mut self) -> Self {
294 self.config.use_moffat_fit = false;
295 self
296 }
297
298 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
300 self.thread_pool = Some(pool);
301 self
302 }
303
304 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
306 let path = path.as_ref();
307 match &self.thread_pool {
308 Some(pool) => pool.install(|| self.analyze_impl(path)),
309 None => self.analyze_impl(path),
310 }
311 }
312
313 pub fn analyze_data(
320 &self,
321 data: &[f32],
322 width: usize,
323 height: usize,
324 channels: usize,
325 ) -> Result<AnalysisResult> {
326 match &self.thread_pool {
327 Some(pool) => pool.install(|| {
328 self.run_analysis(data, width, height, channels, None)
329 }),
330 None => self.run_analysis(data, width, height, channels, None),
331 }
332 }
333
334 pub fn analyze_raw(
339 &self,
340 meta: &ImageMetadata,
341 pixels: &PixelData,
342 ) -> Result<AnalysisResult> {
343 match &self.thread_pool {
344 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
345 None => self.analyze_raw_impl(meta, pixels),
346 }
347 }
348
349 fn analyze_raw_impl(
350 &self,
351 meta: &ImageMetadata,
352 pixels: &PixelData,
353 ) -> Result<AnalysisResult> {
354 let f32_data = match pixels {
355 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
356 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
357 };
358
359 let mut data = f32_data.into_owned();
360 let width = meta.width;
361 let height = meta.height;
362 let channels = meta.channels;
363
364 let green_mask = if self.config.apply_debayer
365 && meta.bayer_pattern != BayerPattern::None
366 && channels == 1
367 {
368 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
369 Some(build_green_mask(width, height, meta.bayer_pattern))
370 } else {
371 None
372 };
373
374 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
375 }
376
377 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
378 let (meta, pixel_data) =
379 formats::read_image(path).context("Failed to read image for analysis")?;
380
381 let f32_data = match pixel_data {
383 PixelData::Float32(d) => d,
384 PixelData::Uint16(d) => u16_to_f32(&d),
385 };
386
387 let mut data = f32_data;
388 let width = meta.width;
389 let height = meta.height;
390 let channels = meta.channels;
391
392 let green_mask = if self.config.apply_debayer
395 && meta.bayer_pattern != BayerPattern::None
396 && channels == 1
397 {
398 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
399 Some(build_green_mask(width, height, meta.bayer_pattern))
401 } else {
402 None
403 };
404
405 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
406 }
407
408 fn run_analysis(
409 &self,
410 data: &[f32],
411 width: usize,
412 height: usize,
413 channels: usize,
414 green_mask: Option<&[bool]>,
415 ) -> Result<AnalysisResult> {
416 let lum = if channels == 3 {
418 extract_luminance(data, width, height)
419 } else {
420 data[..width * height].to_vec()
421 };
422
423 let det_params = DetectionParams {
425 detection_sigma: self.config.detection_sigma,
426 min_star_area: self.config.min_star_area,
427 max_star_area: self.config.max_star_area,
428 saturation_limit: self.config.saturation_fraction * 65535.0,
429 };
430
431 let n_iterations = if self.config.background_mesh_size.is_some() {
433 self.config.iterative_background
434 } else {
435 1 };
437
438 let mut bg_result = if let Some(cell_size) = self.config.background_mesh_size {
439 background::estimate_background_mesh(&lum, width, height, cell_size)
440 } else {
441 background::estimate_background(&lum, width, height)
442 };
443
444 if self.config.noise_layers > 0 {
446 bg_result.noise = background::estimate_noise_mrs(
447 &lum, width, height, self.config.noise_layers,
448 ).max(0.001);
449 }
450
451 let mut detected;
452 let final_fwhm;
453
454 {
456 let bg_map_ref = bg_result.background_map.as_deref();
457 let noise_map_ref = bg_result.noise_map.as_deref();
458 let initial_fwhm = 3.0_f32;
459 let pass1 = detection::detect_stars(
460 &lum, width, height,
461 bg_result.background, bg_result.noise,
462 bg_map_ref, noise_map_ref, &det_params, initial_fwhm,
463 );
464
465 let measured_kernel_fwhm = estimate_fwhm_from_stars(
466 &lum, width, height, &pass1, bg_result.background, bg_map_ref,
467 );
468
469 let capped_kernel_fwhm = measured_kernel_fwhm.min(initial_fwhm * 2.0);
471
472 if capped_kernel_fwhm > 0.0
473 && ((capped_kernel_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
474 {
475 detected = detection::detect_stars(
476 &lum, width, height,
477 bg_result.background, bg_result.noise,
478 bg_map_ref, noise_map_ref, &det_params, capped_kernel_fwhm,
479 );
480 final_fwhm = capped_kernel_fwhm;
481 } else {
482 detected = pass1;
483 final_fwhm = initial_fwhm;
484 }
485 }
486
487 if let Some(cell_size) = self.config.background_mesh_size {
489 for _ in 1..n_iterations {
490 if detected.is_empty() {
491 break;
492 }
493
494 let mask_radius = (2.5 * final_fwhm).ceil() as i32;
496 let mask_r_sq = (mask_radius * mask_radius) as f32;
497 let mut source_mask = vec![false; width * height];
498 for star in &detected {
499 let cx = star.x.round() as i32;
500 let cy = star.y.round() as i32;
501 for dy in -mask_radius..=mask_radius {
502 let py = cy + dy;
503 if py < 0 || py >= height as i32 { continue; }
504 for dx in -mask_radius..=mask_radius {
505 let px = cx + dx;
506 if px < 0 || px >= width as i32 { continue; }
507 if (dx * dx + dy * dy) as f32 <= mask_r_sq {
508 source_mask[py as usize * width + px as usize] = true;
509 }
510 }
511 }
512 }
513
514 bg_result = background::estimate_background_mesh_masked(
516 &lum, width, height, cell_size, &source_mask,
517 );
518
519 if self.config.noise_layers > 0 {
521 bg_result.noise = background::estimate_noise_mrs(
522 &lum, width, height, self.config.noise_layers,
523 ).max(0.001);
524 }
525
526 let bg_map_ref = bg_result.background_map.as_deref();
528 let noise_map_ref = bg_result.noise_map.as_deref();
529 detected = detection::detect_stars(
530 &lum, width, height,
531 bg_result.background, bg_result.noise,
532 bg_map_ref, noise_map_ref, &det_params, final_fwhm,
533 );
534 }
535 }
536
537 let bg_map_ref = bg_result.background_map.as_deref();
538
539 let detection_threshold = self.config.detection_sigma * bg_result.noise;
540
541 let (trail_r_squared, possibly_trailed) = if detected.len() >= 5 {
555 let n = detected.len();
556 let (sum_cos, sum_sin) =
557 detected
558 .iter()
559 .fold((0.0f64, 0.0f64), |(sc, ss), s| {
560 let a = 2.0 * s.theta as f64;
561 (sc + a.cos(), ss + a.sin())
562 });
563 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
564 let p = (-(n as f64) * r_sq).exp();
565
566 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
567 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
568 let median_ecc = eccs[eccs.len() / 2];
569
570 let threshold = self.config.trail_r_squared_threshold as f64;
571 let trailed = (r_sq > threshold && p < 0.01)
572 || (median_ecc > 0.6 && p < 0.05);
573
574 (r_sq as f32, trailed)
575 } else {
576 (0.0, false)
577 };
578
579 let snr_db = snr::compute_snr_db(&lum, bg_result.noise);
580 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
581
582 let make_zero_result = |stars_detected: usize| {
584 Ok(AnalysisResult {
585 width,
586 height,
587 source_channels: channels,
588 background: bg_result.background,
589 noise: bg_result.noise,
590 detection_threshold,
591 stars_detected,
592 stars: Vec::new(),
593 median_fwhm: 0.0,
594 median_eccentricity: 0.0,
595 median_snr: 0.0,
596 median_hfr: 0.0,
597 snr_db,
598 snr_weight,
599 psf_signal: 0.0,
600 trail_r_squared,
601 possibly_trailed,
602 measured_fwhm_kernel: final_fwhm,
603 median_beta: None,
604 })
605 };
606
607 if detected.is_empty() {
608 return make_zero_result(0);
609 }
610
611 if let Some(max_m) = self.config.max_measure {
614 detected.truncate(max_m);
615 }
616
617 if let Some(max_ecc) = self.config.max_distortion {
619 detected.retain(|s| s.eccentricity <= max_ecc);
620 }
621
622 let mut measured = metrics::measure_stars(
624 &lum,
625 width,
626 height,
627 &detected,
628 bg_result.background,
629 bg_map_ref,
630 self.config.use_gaussian_fit,
631 self.config.use_moffat_fit,
632 green_mask,
633 self.config.moffat_beta.map(|b| b as f64),
634 );
635
636 if measured.is_empty() {
637 return make_zero_result(0);
638 }
639
640 let stars_detected = measured.len();
642
643 let fwhm_vals: Vec<f32> = measured.iter().map(|s| s.fwhm).collect();
646 let median_fwhm = sigma_clipped_median(&fwhm_vals);
647
648 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
650
651 let ecc_vals: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
653 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
654 let hfr_vals: Vec<f32> = measured.iter().map(|s| s.hfr).collect();
655
656 let median_eccentricity = sigma_clipped_median(&ecc_vals);
657 let median_snr = find_median(&mut snr_vals); let median_hfr = sigma_clipped_median(&hfr_vals);
659
660 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
661
662 let median_beta = if self.config.use_moffat_fit {
664 let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
665 if beta_vals.is_empty() {
666 None
667 } else {
668 Some(find_median(&mut beta_vals))
669 }
670 } else {
671 None
672 };
673
674 measured.truncate(self.config.max_stars);
676
677 let stars: Vec<StarMetrics> = measured
679 .into_iter()
680 .map(|m| StarMetrics {
681 x: m.x,
682 y: m.y,
683 peak: m.peak,
684 flux: m.flux,
685 fwhm_x: m.fwhm_x,
686 fwhm_y: m.fwhm_y,
687 fwhm: m.fwhm,
688 eccentricity: m.eccentricity,
689 snr: m.snr,
690 hfr: m.hfr,
691 theta: m.theta,
692 beta: m.beta,
693 })
694 .collect();
695
696 Ok(AnalysisResult {
697 width,
698 height,
699 source_channels: channels,
700 background: bg_result.background,
701 noise: bg_result.noise,
702 detection_threshold,
703 stars_detected,
704 stars,
705 median_fwhm,
706 median_eccentricity,
707 median_snr,
708 median_hfr,
709 snr_db,
710 snr_weight,
711 psf_signal,
712 trail_r_squared,
713 possibly_trailed,
714 measured_fwhm_kernel: final_fwhm,
715 median_beta,
716 })
717 }
718}
719
720impl Default for ImageAnalyzer {
721 fn default() -> Self {
722 Self::new()
723 }
724}
725
726pub fn sigma_clipped_median(values: &[f32]) -> f32 {
735 if values.is_empty() {
736 return 0.0;
737 }
738 let mut v: Vec<f32> = values.to_vec();
739 for _ in 0..2 {
740 if v.len() < 3 {
741 break;
742 }
743 let med = find_median(&mut v);
744 let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
745 let mad = find_median(&mut abs_devs);
746 let sigma_mad = 1.4826 * mad;
747 if sigma_mad < 1e-10 {
748 break; }
750 let clip = 3.0 * sigma_mad;
751 v.retain(|&x| (x - med).abs() <= clip);
752 }
753 if v.is_empty() {
754 return find_median(&mut values.to_vec());
755 }
756 find_median(&mut v)
757}
758
759pub fn estimate_fwhm_from_stars(
763 lum: &[f32],
764 width: usize,
765 height: usize,
766 stars: &[detection::DetectedStar],
767 background: f32,
768 bg_map: Option<&[f32]>,
769) -> f32 {
770 let scan_n = stars.len().min(50);
774 if scan_n < 3 {
775 return 0.0;
776 }
777
778 let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
779 .iter()
780 .filter(|s| s.eccentricity <= 0.7)
781 .take(20)
782 .collect();
783 if round_stars.len() < 3 {
784 return 0.0;
785 }
786
787 let mut fwhm_vals = Vec::with_capacity(round_stars.len());
788 for star in &round_stars {
789 let stamp_radius = 12_usize; let cx = star.x.round() as i32;
791 let cy = star.y.round() as i32;
792 let sr = stamp_radius as i32;
793 if cx - sr <= 0 || cy - sr <= 0
794 || cx + sr >= width as i32 - 1
795 || cy + sr >= height as i32 - 1
796 {
797 continue;
798 }
799 let x0 = (cx - sr) as usize;
800 let y0 = (cy - sr) as usize;
801 let x1 = (cx + sr) as usize;
802 let y1 = (cy + sr) as usize;
803 let stamp_w = x1 - x0 + 1;
804 let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
805 for sy in y0..=y1 {
806 for sx in x0..=x1 {
807 let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
808 stamp.push(lum[sy * width + sx] - bg);
809 }
810 }
811 let rel_cx = star.x - x0 as f32;
812 let rel_cy = star.y - y0 as f32;
813 let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
814 let fwhm = sigma * 2.3548;
815 if fwhm > 1.0 && fwhm < 20.0 {
816 fwhm_vals.push(fwhm);
817 }
818 }
819
820 if fwhm_vals.len() < 3 {
821 return 0.0;
822 }
823 find_median(&mut fwhm_vals)
824}
825
826fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
832 let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
833 (0..height)
834 .flat_map(|y| {
835 (0..width).map(move |x| {
836 let parity = (x + y) & 1;
837 if green_at_even { parity == 0 } else { parity == 1 }
838 })
839 })
840 .collect()
841}
842
843pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
845 use rayon::prelude::*;
846
847 let plane_size = width * height;
848 let r = &data[..plane_size];
849 let g = &data[plane_size..2 * plane_size];
850 let b = &data[2 * plane_size..3 * plane_size];
851
852 let mut lum = vec![0.0_f32; plane_size];
853 const CHUNK: usize = 8192;
854 lum.par_chunks_mut(CHUNK)
855 .enumerate()
856 .for_each(|(ci, chunk)| {
857 let off = ci * CHUNK;
858 for (i, dst) in chunk.iter_mut().enumerate() {
859 let idx = off + i;
860 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
861 }
862 });
863 lum
864}
865
866#[cfg(feature = "debug-pipeline")]
872pub fn prepare_luminance(
873 meta: &crate::types::ImageMetadata,
874 pixels: &crate::types::PixelData,
875 apply_debayer: bool,
876) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
877 use crate::processing::color::u16_to_f32;
878 use crate::processing::debayer;
879
880 let f32_data = match pixels {
881 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
882 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
883 };
884
885 let mut data = f32_data.into_owned();
886 let width = meta.width;
887 let height = meta.height;
888 let channels = meta.channels;
889
890 let green_mask = if apply_debayer
891 && meta.bayer_pattern != BayerPattern::None
892 && channels == 1
893 {
894 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
895 Some(build_green_mask(width, height, meta.bayer_pattern))
896 } else {
897 None
898 };
899
900 let lum = if channels == 3 {
901 extract_luminance(&data, width, height)
902 } else {
903 data[..width * height].to_vec()
904 };
905
906 (lum, width, height, channels, green_mask)
907}