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