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}
93
94pub struct AnalysisResult {
96 pub width: usize,
98 pub height: usize,
100 pub source_channels: usize,
102 pub background: f32,
104 pub noise: f32,
106 pub detection_threshold: f32,
108 pub stars_detected: usize,
110 pub stars: Vec<StarMetrics>,
112 pub median_fwhm: f32,
114 pub median_eccentricity: f32,
116 pub median_snr: f32,
118 pub median_hfr: f32,
120 pub snr_weight: f32,
122 pub psf_signal: f32,
124 pub frame_snr: f32,
127 pub trail_r_squared: f32,
131 pub possibly_trailed: bool,
134 pub measured_fwhm_kernel: f32,
138 pub median_beta: Option<f32>,
141}
142
143pub struct AnalysisConfig {
145 detection_sigma: f32,
146 min_star_area: usize,
147 max_star_area: usize,
148 saturation_fraction: f32,
149 max_stars: usize,
150 apply_debayer: bool,
151 trail_r_squared_threshold: f32,
152 noise_layers: usize,
154}
155
156pub struct ImageAnalyzer {
158 config: AnalysisConfig,
159 thread_pool: Option<Arc<rayon::ThreadPool>>,
160}
161
162impl ImageAnalyzer {
163 pub fn new() -> Self {
164 ImageAnalyzer {
165 config: AnalysisConfig {
166 detection_sigma: 5.0,
167 min_star_area: 5,
168 max_star_area: 2000,
169 saturation_fraction: 0.95,
170 max_stars: 200,
171 apply_debayer: true,
172 trail_r_squared_threshold: 0.5,
173 noise_layers: 1,
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_stars(mut self, n: usize) -> Self {
205 self.config.max_stars = n.max(1);
206 self
207 }
208
209 pub fn without_debayer(mut self) -> Self {
211 self.config.apply_debayer = false;
212 self
213 }
214
215 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
219 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
220 self
221 }
222
223 pub fn with_mrs_layers(mut self, layers: usize) -> Self {
227 self.config.noise_layers = layers.max(1);
228 self
229 }
230
231 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
233 self.thread_pool = Some(pool);
234 self
235 }
236
237 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
239 let path = path.as_ref();
240 match &self.thread_pool {
241 Some(pool) => pool.install(|| self.analyze_impl(path)),
242 None => self.analyze_impl(path),
243 }
244 }
245
246 pub fn analyze_data(
253 &self,
254 data: &[f32],
255 width: usize,
256 height: usize,
257 channels: usize,
258 ) -> Result<AnalysisResult> {
259 match &self.thread_pool {
260 Some(pool) => pool.install(|| {
261 self.run_analysis(data, width, height, channels, None)
262 }),
263 None => self.run_analysis(data, width, height, channels, None),
264 }
265 }
266
267 pub fn analyze_raw(
272 &self,
273 meta: &ImageMetadata,
274 pixels: &PixelData,
275 ) -> Result<AnalysisResult> {
276 match &self.thread_pool {
277 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
278 None => self.analyze_raw_impl(meta, pixels),
279 }
280 }
281
282 fn analyze_raw_impl(
283 &self,
284 meta: &ImageMetadata,
285 pixels: &PixelData,
286 ) -> Result<AnalysisResult> {
287 let f32_data = match pixels {
288 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
289 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
290 };
291
292 let mut data = f32_data.into_owned();
293 let width = meta.width;
294 let height = meta.height;
295 let channels = meta.channels;
296
297 let green_mask = if self.config.apply_debayer
298 && meta.bayer_pattern != BayerPattern::None
299 && channels == 1
300 {
301 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
302 Some(build_green_mask(width, height, meta.bayer_pattern))
303 } else {
304 None
305 };
306
307 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
308 }
309
310 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
311 let (meta, pixel_data) =
312 formats::read_image(path).context("Failed to read image for analysis")?;
313
314 let f32_data = match pixel_data {
316 PixelData::Float32(d) => d,
317 PixelData::Uint16(d) => u16_to_f32(&d),
318 };
319
320 let mut data = f32_data;
321 let width = meta.width;
322 let height = meta.height;
323 let channels = meta.channels;
324
325 let green_mask = if self.config.apply_debayer
328 && meta.bayer_pattern != BayerPattern::None
329 && channels == 1
330 {
331 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
332 Some(build_green_mask(width, height, meta.bayer_pattern))
334 } else {
335 None
336 };
337
338 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
339 }
340
341 fn run_analysis(
342 &self,
343 data: &[f32],
344 width: usize,
345 height: usize,
346 channels: usize,
347 green_mask: Option<&[bool]>,
348 ) -> Result<AnalysisResult> {
349 let lum = if channels == 3 {
351 extract_luminance(data, width, height)
352 } else {
353 data[..width * height].to_vec()
354 };
355
356 let det_params = DetectionParams {
357 detection_sigma: self.config.detection_sigma,
358 min_star_area: self.config.min_star_area,
359 max_star_area: self.config.max_star_area,
360 saturation_limit: self.config.saturation_fraction * 65535.0,
361 };
362
363 let cell_size = background::auto_cell_size(width, height);
365 let mut bg_result = background::estimate_background_mesh(&lum, width, height, cell_size);
366 bg_result.noise = background::estimate_noise_mrs(
367 &lum, width, height, self.config.noise_layers.max(1),
368 ).max(0.001);
369
370 let initial_fwhm = 3.0_f32;
372 let pass1_stars = {
373 let bg_map = bg_result.background_map.as_deref();
374 let noise_map = bg_result.noise_map.as_deref();
375 detection::detect_stars(
376 &lum, width, height,
377 bg_result.background, bg_result.noise,
378 bg_map, noise_map, &det_params, initial_fwhm,
379 )
380 };
381
382 let calibration_stars: Vec<&detection::DetectedStar> = pass1_stars
384 .iter()
385 .filter(|s| s.eccentricity < 0.5 && s.area >= 5)
386 .take(100)
387 .collect();
388
389 let field_beta: Option<f64>;
391 let field_fwhm: f32;
392 if calibration_stars.len() >= 3 {
393 let cal_owned: Vec<detection::DetectedStar> = calibration_stars
394 .iter()
395 .map(|s| detection::DetectedStar {
396 x: s.x, y: s.y, peak: s.peak, flux: s.flux,
397 area: s.area, theta: s.theta, eccentricity: s.eccentricity,
398 })
399 .collect();
400 let cal_measured = metrics::measure_stars(
401 &lum, width, height, &cal_owned,
402 bg_result.background,
403 bg_result.background_map.as_deref(),
404 green_mask,
405 None, );
407
408 let mut beta_vals: Vec<f32> = cal_measured.iter().filter_map(|s| s.beta).collect();
409 let mut fwhm_vals: Vec<f32> = cal_measured.iter().map(|s| s.fwhm).collect();
410
411 if beta_vals.len() >= 3 {
412 field_beta = Some(sigma_clipped_median(&beta_vals) as f64);
413 } else if !beta_vals.is_empty() {
414 field_beta = Some(find_median(&mut beta_vals) as f64);
415 } else {
416 field_beta = None;
417 }
418
419 if fwhm_vals.len() >= 3 {
420 field_fwhm = sigma_clipped_median(&fwhm_vals);
421 } else if !fwhm_vals.is_empty() {
422 field_fwhm = find_median(&mut fwhm_vals);
423 } else {
424 field_fwhm = estimate_fwhm_from_stars(
425 &lum, width, height, &pass1_stars,
426 bg_result.background, bg_result.background_map.as_deref(),
427 );
428 }
429 } else {
430 field_beta = None;
432 field_fwhm = estimate_fwhm_from_stars(
433 &lum, width, height, &pass1_stars,
434 bg_result.background, bg_result.background_map.as_deref(),
435 );
436 }
437
438 let mask_fwhm = if field_fwhm > 1.0 { field_fwhm } else { initial_fwhm };
440 if !pass1_stars.is_empty() {
441 let mask_radius = (2.5 * mask_fwhm).ceil() as i32;
442 let mask_r_sq = (mask_radius * mask_radius) as f32;
443 let mut source_mask = vec![false; width * height];
444 for star in &pass1_stars {
445 let cx = star.x.round() as i32;
446 let cy = star.y.round() as i32;
447 for dy in -mask_radius..=mask_radius {
448 let py = cy + dy;
449 if py < 0 || py >= height as i32 { continue; }
450 for dx in -mask_radius..=mask_radius {
451 let px = cx + dx;
452 if px < 0 || px >= width as i32 { continue; }
453 if (dx * dx + dy * dy) as f32 <= mask_r_sq {
454 source_mask[py as usize * width + px as usize] = true;
455 }
456 }
457 }
458 }
459 bg_result = background::estimate_background_mesh_masked(
460 &lum, width, height, cell_size, &source_mask,
461 );
462 bg_result.noise = background::estimate_noise_mrs(
463 &lum, width, height, self.config.noise_layers.max(1),
464 ).max(0.001);
465 }
466
467 let final_fwhm = if field_fwhm > 1.0
469 && ((field_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
470 {
471 field_fwhm.min(initial_fwhm * 2.0)
472 } else {
473 initial_fwhm
474 };
475
476 let detected = {
477 let bg_map = bg_result.background_map.as_deref();
478 let noise_map = bg_result.noise_map.as_deref();
479 detection::detect_stars(
480 &lum, width, height,
481 bg_result.background, bg_result.noise,
482 bg_map, noise_map, &det_params, final_fwhm,
483 )
484 };
485
486 let bg_map_ref = bg_result.background_map.as_deref();
487 let detection_threshold = self.config.detection_sigma * bg_result.noise;
488
489 let (trail_r_squared, possibly_trailed) = if detected.len() >= 5 {
491 let n = detected.len();
492 let (sum_cos, sum_sin) =
493 detected.iter().fold((0.0f64, 0.0f64), |(sc, ss), s| {
494 let a = 2.0 * s.theta as f64;
495 (sc + a.cos(), ss + a.sin())
496 });
497 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
498 let p = (-(n as f64) * r_sq).exp();
499 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
500 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
501 let median_ecc = eccs[eccs.len() / 2];
502 let threshold = self.config.trail_r_squared_threshold as f64;
503 let trailed = (r_sq > threshold && p < 0.01)
504 || (median_ecc > 0.6 && p < 0.05);
505 (r_sq as f32, trailed)
506 } else {
507 (0.0, false)
508 };
509
510 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
511 let frame_snr = if bg_result.noise > 0.0 { bg_result.background / bg_result.noise } else { 0.0 };
512
513 let make_zero_result = |stars_detected: usize| {
514 Ok(AnalysisResult {
515 width, height, source_channels: channels,
516 background: bg_result.background, noise: bg_result.noise,
517 detection_threshold, stars_detected,
518 stars: Vec::new(),
519 median_fwhm: 0.0, median_eccentricity: 0.0,
520 median_snr: 0.0, median_hfr: 0.0,
521 snr_weight, psf_signal: 0.0, frame_snr,
522 trail_r_squared, possibly_trailed,
523 measured_fwhm_kernel: final_fwhm,
524 median_beta: field_beta.map(|b| b as f32),
525 })
526 };
527
528 if detected.is_empty() {
529 return make_zero_result(0);
530 }
531
532 let mut measured = metrics::measure_stars(
534 &lum, width, height, &detected,
535 bg_result.background, bg_map_ref,
536 green_mask, field_beta,
537 );
538
539 if measured.is_empty() {
540 return make_zero_result(0);
541 }
542
543 let stars_detected = measured.len();
545
546 let fwhm_vals: Vec<f32> = measured.iter().map(|s| s.fwhm).collect();
547 let median_fwhm = sigma_clipped_median(&fwhm_vals);
548
549 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
550
551 let ecc_vals: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
552 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
553 let hfr_vals: Vec<f32> = measured.iter().map(|s| s.hfr).collect();
554
555 let median_eccentricity = sigma_clipped_median(&ecc_vals);
556 let median_snr = find_median(&mut snr_vals);
557 let median_hfr = sigma_clipped_median(&hfr_vals);
558 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
559
560 let median_beta = if let Some(fb) = field_beta {
562 Some(fb as f32)
563 } else {
564 let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
565 if beta_vals.is_empty() { None } else { Some(find_median(&mut beta_vals)) }
566 };
567
568 measured.truncate(self.config.max_stars);
570
571 let stars: Vec<StarMetrics> = measured
572 .into_iter()
573 .map(|m| StarMetrics {
574 x: m.x, y: m.y, peak: m.peak, flux: m.flux,
575 fwhm_x: m.fwhm_x, fwhm_y: m.fwhm_y, fwhm: m.fwhm,
576 eccentricity: m.eccentricity, snr: m.snr, hfr: m.hfr,
577 theta: m.theta, beta: m.beta, fit_method: m.fit_method,
578 })
579 .collect();
580
581 Ok(AnalysisResult {
582 width, height, source_channels: channels,
583 background: bg_result.background, noise: bg_result.noise,
584 detection_threshold, stars_detected, stars,
585 median_fwhm, median_eccentricity, median_snr, median_hfr,
586 snr_weight, psf_signal, frame_snr,
587 trail_r_squared, possibly_trailed,
588 measured_fwhm_kernel: final_fwhm,
589 median_beta,
590 })
591 }
592}
593
594impl Default for ImageAnalyzer {
595 fn default() -> Self {
596 Self::new()
597 }
598}
599
600pub fn sigma_clipped_median(values: &[f32]) -> f32 {
609 if values.is_empty() {
610 return 0.0;
611 }
612 let mut v: Vec<f32> = values.to_vec();
613 for _ in 0..2 {
614 if v.len() < 3 {
615 break;
616 }
617 let med = find_median(&mut v);
618 let mut abs_devs: Vec<f32> = v.iter().map(|&x| (x - med).abs()).collect();
619 let mad = find_median(&mut abs_devs);
620 let sigma_mad = 1.4826 * mad;
621 if sigma_mad < 1e-10 {
622 break; }
624 let clip = 3.0 * sigma_mad;
625 v.retain(|&x| (x - med).abs() <= clip);
626 }
627 if v.is_empty() {
628 return find_median(&mut values.to_vec());
629 }
630 find_median(&mut v)
631}
632
633pub fn estimate_fwhm_from_stars(
637 lum: &[f32],
638 width: usize,
639 height: usize,
640 stars: &[detection::DetectedStar],
641 background: f32,
642 bg_map: Option<&[f32]>,
643) -> f32 {
644 let scan_n = stars.len().min(50);
648 if scan_n < 3 {
649 return 0.0;
650 }
651
652 let round_stars: Vec<&detection::DetectedStar> = stars[..scan_n]
653 .iter()
654 .filter(|s| s.eccentricity <= 0.7)
655 .take(20)
656 .collect();
657 if round_stars.len() < 3 {
658 return 0.0;
659 }
660
661 let mut fwhm_vals = Vec::with_capacity(round_stars.len());
662 for star in &round_stars {
663 let stamp_radius = 12_usize; let cx = star.x.round() as i32;
665 let cy = star.y.round() as i32;
666 let sr = stamp_radius as i32;
667 if cx - sr <= 0 || cy - sr <= 0
668 || cx + sr >= width as i32 - 1
669 || cy + sr >= height as i32 - 1
670 {
671 continue;
672 }
673 let x0 = (cx - sr) as usize;
674 let y0 = (cy - sr) as usize;
675 let x1 = (cx + sr) as usize;
676 let y1 = (cy + sr) as usize;
677 let stamp_w = x1 - x0 + 1;
678 let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
679 for sy in y0..=y1 {
680 for sx in x0..=x1 {
681 let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
682 stamp.push(lum[sy * width + sx] - bg);
683 }
684 }
685 let rel_cx = star.x - x0 as f32;
686 let rel_cy = star.y - y0 as f32;
687 let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
688 let fwhm = sigma * 2.3548;
689 if fwhm > 1.0 && fwhm < 20.0 {
690 fwhm_vals.push(fwhm);
691 }
692 }
693
694 if fwhm_vals.len() < 3 {
695 return 0.0;
696 }
697 find_median(&mut fwhm_vals)
698}
699
700fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
706 let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
707 (0..height)
708 .flat_map(|y| {
709 (0..width).map(move |x| {
710 let parity = (x + y) & 1;
711 if green_at_even { parity == 0 } else { parity == 1 }
712 })
713 })
714 .collect()
715}
716
717pub fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
719 use rayon::prelude::*;
720
721 let plane_size = width * height;
722 let r = &data[..plane_size];
723 let g = &data[plane_size..2 * plane_size];
724 let b = &data[2 * plane_size..3 * plane_size];
725
726 let mut lum = vec![0.0_f32; plane_size];
727 const CHUNK: usize = 8192;
728 lum.par_chunks_mut(CHUNK)
729 .enumerate()
730 .for_each(|(ci, chunk)| {
731 let off = ci * CHUNK;
732 for (i, dst) in chunk.iter_mut().enumerate() {
733 let idx = off + i;
734 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
735 }
736 });
737 lum
738}
739
740#[cfg(feature = "debug-pipeline")]
746pub fn prepare_luminance(
747 meta: &crate::types::ImageMetadata,
748 pixels: &crate::types::PixelData,
749 apply_debayer: bool,
750) -> (Vec<f32>, usize, usize, usize, Option<Vec<bool>>) {
751 use crate::processing::color::u16_to_f32;
752 use crate::processing::debayer;
753
754 let f32_data = match pixels {
755 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
756 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
757 };
758
759 let mut data = f32_data.into_owned();
760 let width = meta.width;
761 let height = meta.height;
762 let channels = meta.channels;
763
764 let green_mask = if apply_debayer
765 && meta.bayer_pattern != BayerPattern::None
766 && channels == 1
767 {
768 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
769 Some(build_green_mask(width, height, meta.bayer_pattern))
770 } else {
771 None
772 };
773
774 let lum = if channels == 3 {
775 extract_luminance(&data, width, height)
776 } else {
777 data[..width * height].to_vec()
778 };
779
780 (lum, width, height, channels, green_mask)
781}