1mod background;
4mod convolution;
5mod detection;
6mod fitting;
7mod metrics;
8mod snr;
9
10use std::path::Path;
11use std::sync::Arc;
12
13use anyhow::{Context, Result};
14
15use crate::formats;
16use crate::processing::color::u16_to_f32;
17use crate::processing::debayer;
18use crate::processing::stretch::find_median;
19use crate::types::{BayerPattern, ImageMetadata, PixelData};
20
21use detection::DetectionParams;
22
23pub struct StarMetrics {
25 pub x: f32,
27 pub y: f32,
29 pub peak: f32,
31 pub flux: f32,
33 pub fwhm_x: f32,
35 pub fwhm_y: f32,
37 pub fwhm: f32,
39 pub eccentricity: f32,
41 pub snr: f32,
43 pub hfr: f32,
45 pub theta: f32,
49 pub beta: Option<f32>,
51}
52
53pub struct AnalysisResult {
55 pub width: usize,
57 pub height: usize,
59 pub source_channels: usize,
61 pub background: f32,
63 pub noise: f32,
65 pub detection_threshold: f32,
67 pub stars_detected: usize,
69 pub stars: Vec<StarMetrics>,
71 pub median_fwhm: f32,
73 pub median_eccentricity: f32,
75 pub median_snr: f32,
77 pub median_hfr: f32,
79 pub snr_db: f32,
81 pub snr_weight: f32,
83 pub psf_signal: f32,
85 pub trail_r_squared: f32,
89 pub possibly_trailed: bool,
92 pub measured_fwhm_kernel: f32,
96 pub median_beta: Option<f32>,
99}
100
101pub struct AnalysisConfig {
103 detection_sigma: f32,
104 min_star_area: usize,
105 max_star_area: usize,
106 saturation_fraction: f32,
107 max_measure: Option<usize>,
108 max_stars: usize,
109 use_gaussian_fit: bool,
110 background_mesh_size: Option<usize>,
111 apply_debayer: bool,
112 trail_r_squared_threshold: f32,
113 use_moffat_fit: bool,
114 iterative_background: usize,
115}
116
117pub struct ImageAnalyzer {
119 config: AnalysisConfig,
120 thread_pool: Option<Arc<rayon::ThreadPool>>,
121}
122
123impl ImageAnalyzer {
124 pub fn new() -> Self {
125 ImageAnalyzer {
126 config: AnalysisConfig {
127 detection_sigma: 5.0,
128 min_star_area: 5,
129 max_star_area: 2000,
130 saturation_fraction: 0.95,
131 max_measure: None,
132 max_stars: 200,
133 use_gaussian_fit: true,
134 background_mesh_size: None,
135 apply_debayer: true,
136 trail_r_squared_threshold: 0.5,
137 use_moffat_fit: false,
138 iterative_background: 1,
139 },
140 thread_pool: None,
141 }
142 }
143
144 pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
146 self.config.detection_sigma = sigma.max(1.0);
147 self
148 }
149
150 pub fn with_min_star_area(mut self, area: usize) -> Self {
152 self.config.min_star_area = area.max(1);
153 self
154 }
155
156 pub fn with_max_star_area(mut self, area: usize) -> Self {
158 self.config.max_star_area = area;
159 self
160 }
161
162 pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
164 self.config.saturation_fraction = frac.clamp(0.5, 1.0);
165 self
166 }
167
168 pub fn with_max_measure(mut self, n: usize) -> Self {
173 self.config.max_measure = Some(n.max(100));
174 self
175 }
176
177 pub fn with_max_stars(mut self, n: usize) -> Self {
179 let n = n.max(1);
180 self.config.max_stars = match self.config.max_measure {
182 Some(mm) => n.min(mm),
183 None => n,
184 };
185 self
186 }
187
188 pub fn without_gaussian_fit(mut self) -> Self {
190 self.config.use_gaussian_fit = false;
191 self
192 }
193
194 pub fn with_background_mesh(mut self, cell_size: usize) -> Self {
196 self.config.background_mesh_size = Some(cell_size.max(16));
197 self
198 }
199
200 pub fn without_debayer(mut self) -> Self {
202 self.config.apply_debayer = false;
203 self
204 }
205
206 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
210 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
211 self
212 }
213
214 pub fn with_iterative_background(mut self, iterations: usize) -> Self {
219 self.config.iterative_background = iterations.max(1);
220 self
221 }
222
223 pub fn with_moffat_fit(mut self) -> Self {
226 self.config.use_moffat_fit = true;
227 self
228 }
229
230 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
232 self.thread_pool = Some(pool);
233 self
234 }
235
236 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
238 let path = path.as_ref();
239 match &self.thread_pool {
240 Some(pool) => pool.install(|| self.analyze_impl(path)),
241 None => self.analyze_impl(path),
242 }
243 }
244
245 pub fn analyze_data(
252 &self,
253 data: &[f32],
254 width: usize,
255 height: usize,
256 channels: usize,
257 ) -> Result<AnalysisResult> {
258 match &self.thread_pool {
259 Some(pool) => pool.install(|| {
260 self.run_analysis(data, width, height, channels, None)
261 }),
262 None => self.run_analysis(data, width, height, channels, None),
263 }
264 }
265
266 pub fn analyze_raw(
271 &self,
272 meta: &ImageMetadata,
273 pixels: &PixelData,
274 ) -> Result<AnalysisResult> {
275 match &self.thread_pool {
276 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
277 None => self.analyze_raw_impl(meta, pixels),
278 }
279 }
280
281 fn analyze_raw_impl(
282 &self,
283 meta: &ImageMetadata,
284 pixels: &PixelData,
285 ) -> Result<AnalysisResult> {
286 let f32_data = match pixels {
287 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
288 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
289 };
290
291 let mut data = f32_data.into_owned();
292 let width = meta.width;
293 let height = meta.height;
294 let channels = meta.channels;
295
296 let green_mask = if self.config.apply_debayer
297 && meta.bayer_pattern != BayerPattern::None
298 && channels == 1
299 {
300 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
301 Some(build_green_mask(width, height, meta.bayer_pattern))
302 } else {
303 None
304 };
305
306 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
307 }
308
309 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
310 let (meta, pixel_data) =
311 formats::read_image(path).context("Failed to read image for analysis")?;
312
313 let f32_data = match pixel_data {
315 PixelData::Float32(d) => d,
316 PixelData::Uint16(d) => u16_to_f32(&d),
317 };
318
319 let mut data = f32_data;
320 let width = meta.width;
321 let height = meta.height;
322 let channels = meta.channels;
323
324 let green_mask = if self.config.apply_debayer
327 && meta.bayer_pattern != BayerPattern::None
328 && channels == 1
329 {
330 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
331 Some(build_green_mask(width, height, meta.bayer_pattern))
333 } else {
334 None
335 };
336
337 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
338 }
339
340 fn run_analysis(
341 &self,
342 data: &[f32],
343 width: usize,
344 height: usize,
345 channels: usize,
346 green_mask: Option<&[bool]>,
347 ) -> Result<AnalysisResult> {
348 let lum = if channels == 3 {
350 extract_luminance(data, width, height)
351 } else {
352 data[..width * height].to_vec()
353 };
354
355 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 n_iterations = if self.config.background_mesh_size.is_some() {
365 self.config.iterative_background
366 } else {
367 1 };
369
370 let mut bg_result = if let Some(cell_size) = self.config.background_mesh_size {
371 background::estimate_background_mesh(&lum, width, height, cell_size)
372 } else {
373 background::estimate_background(&lum, width, height)
374 };
375
376 let mut detected;
377 let final_fwhm;
378
379 {
381 let bg_map_ref = bg_result.background_map.as_deref();
382 let noise_map_ref = bg_result.noise_map.as_deref();
383 let initial_fwhm = 3.0_f32;
384 let pass1 = detection::detect_stars(
385 &lum, width, height,
386 bg_result.background, bg_result.noise,
387 bg_map_ref, noise_map_ref, &det_params, initial_fwhm,
388 );
389
390 let measured_kernel_fwhm = estimate_fwhm_from_stars(
391 &lum, width, height, &pass1, bg_result.background, bg_map_ref,
392 );
393
394 if measured_kernel_fwhm > 0.0
395 && ((measured_kernel_fwhm - initial_fwhm) / initial_fwhm).abs() > 0.30
396 {
397 detected = detection::detect_stars(
398 &lum, width, height,
399 bg_result.background, bg_result.noise,
400 bg_map_ref, noise_map_ref, &det_params, measured_kernel_fwhm,
401 );
402 final_fwhm = measured_kernel_fwhm;
403 } else {
404 detected = pass1;
405 final_fwhm = initial_fwhm;
406 }
407 }
408
409 if let Some(cell_size) = self.config.background_mesh_size {
411 for _ in 1..n_iterations {
412 if detected.is_empty() {
413 break;
414 }
415
416 let mask_radius = (2.5 * final_fwhm).ceil() as i32;
418 let mask_r_sq = (mask_radius * mask_radius) as f32;
419 let mut source_mask = vec![false; width * height];
420 for star in &detected {
421 let cx = star.x.round() as i32;
422 let cy = star.y.round() as i32;
423 for dy in -mask_radius..=mask_radius {
424 let py = cy + dy;
425 if py < 0 || py >= height as i32 { continue; }
426 for dx in -mask_radius..=mask_radius {
427 let px = cx + dx;
428 if px < 0 || px >= width as i32 { continue; }
429 if (dx * dx + dy * dy) as f32 <= mask_r_sq {
430 source_mask[py as usize * width + px as usize] = true;
431 }
432 }
433 }
434 }
435
436 bg_result = background::estimate_background_mesh_masked(
438 &lum, width, height, cell_size, &source_mask,
439 );
440
441 let bg_map_ref = bg_result.background_map.as_deref();
443 let noise_map_ref = bg_result.noise_map.as_deref();
444 detected = detection::detect_stars(
445 &lum, width, height,
446 bg_result.background, bg_result.noise,
447 bg_map_ref, noise_map_ref, &det_params, final_fwhm,
448 );
449 }
450 }
451
452 let bg_map_ref = bg_result.background_map.as_deref();
453
454 let detection_threshold = self.config.detection_sigma * bg_result.noise;
455
456 let (trail_r_squared, possibly_trailed) = if detected.len() >= 5 {
470 let n = detected.len();
471 let (sum_cos, sum_sin) =
472 detected
473 .iter()
474 .fold((0.0f64, 0.0f64), |(sc, ss), s| {
475 let a = 2.0 * s.theta as f64;
476 (sc + a.cos(), ss + a.sin())
477 });
478 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
479 let p = (-(n as f64) * r_sq).exp();
480
481 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
482 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
483 let median_ecc = eccs[eccs.len() / 2];
484
485 let threshold = self.config.trail_r_squared_threshold as f64;
486 let trailed = (r_sq > threshold && p < 0.01)
487 || (median_ecc > 0.6 && p < 0.05);
488
489 (r_sq as f32, trailed)
490 } else {
491 (0.0, false)
492 };
493
494 let snr_db = snr::compute_snr_db(&lum, bg_result.noise);
495 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
496
497 let make_zero_result = |stars_detected: usize| {
499 Ok(AnalysisResult {
500 width,
501 height,
502 source_channels: channels,
503 background: bg_result.background,
504 noise: bg_result.noise,
505 detection_threshold,
506 stars_detected,
507 stars: Vec::new(),
508 median_fwhm: 0.0,
509 median_eccentricity: 0.0,
510 median_snr: 0.0,
511 median_hfr: 0.0,
512 snr_db,
513 snr_weight,
514 psf_signal: 0.0,
515 trail_r_squared,
516 possibly_trailed,
517 measured_fwhm_kernel: final_fwhm,
518 median_beta: None,
519 })
520 };
521
522 if detected.is_empty() {
523 return make_zero_result(0);
524 }
525
526 if let Some(max_m) = self.config.max_measure {
529 detected.truncate(max_m);
530 }
531
532 let mut measured = metrics::measure_stars(
534 &lum,
535 width,
536 height,
537 &detected,
538 bg_result.background,
539 bg_map_ref,
540 self.config.use_gaussian_fit,
541 self.config.use_moffat_fit,
542 green_mask,
543 );
544
545 if measured.is_empty() {
546 return make_zero_result(0);
547 }
548
549 let stars_detected = measured.len();
551
552 let mut fwhm_vals: Vec<f32> = measured.iter().map(|s| s.fwhm).collect();
554 let median_fwhm = find_median(&mut fwhm_vals);
555
556 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
558
559 let mut ecc_vals: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
561 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
562 let mut hfr_vals: Vec<f32> = measured.iter().map(|s| s.hfr).collect();
563
564 let median_eccentricity = find_median(&mut ecc_vals);
565 let median_snr = find_median(&mut snr_vals);
566 let median_hfr = find_median(&mut hfr_vals);
567
568 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
569
570 let median_beta = if self.config.use_moffat_fit {
572 let mut beta_vals: Vec<f32> = measured.iter().filter_map(|s| s.beta).collect();
573 if beta_vals.is_empty() {
574 None
575 } else {
576 Some(find_median(&mut beta_vals))
577 }
578 } else {
579 None
580 };
581
582 measured.truncate(self.config.max_stars);
584
585 let stars: Vec<StarMetrics> = measured
587 .into_iter()
588 .map(|m| StarMetrics {
589 x: m.x,
590 y: m.y,
591 peak: m.peak,
592 flux: m.flux,
593 fwhm_x: m.fwhm_x,
594 fwhm_y: m.fwhm_y,
595 fwhm: m.fwhm,
596 eccentricity: m.eccentricity,
597 snr: m.snr,
598 hfr: m.hfr,
599 theta: m.theta,
600 beta: m.beta,
601 })
602 .collect();
603
604 Ok(AnalysisResult {
605 width,
606 height,
607 source_channels: channels,
608 background: bg_result.background,
609 noise: bg_result.noise,
610 detection_threshold,
611 stars_detected,
612 stars,
613 median_fwhm,
614 median_eccentricity,
615 median_snr,
616 median_hfr,
617 snr_db,
618 snr_weight,
619 psf_signal,
620 trail_r_squared,
621 possibly_trailed,
622 measured_fwhm_kernel: final_fwhm,
623 median_beta,
624 })
625 }
626}
627
628impl Default for ImageAnalyzer {
629 fn default() -> Self {
630 Self::new()
631 }
632}
633
634fn estimate_fwhm_from_stars(
638 lum: &[f32],
639 width: usize,
640 height: usize,
641 stars: &[detection::DetectedStar],
642 background: f32,
643 bg_map: Option<&[f32]>,
644) -> f32 {
645 let top_n = stars.len().min(20);
647 if top_n < 3 {
648 return 0.0;
649 }
650
651 let mut fwhm_vals = Vec::with_capacity(top_n);
652 for star in &stars[..top_n] {
653 let stamp_radius = 12_usize; let cx = star.x.round() as i32;
655 let cy = star.y.round() as i32;
656 let sr = stamp_radius as i32;
657 if cx - sr <= 0 || cy - sr <= 0
658 || cx + sr >= width as i32 - 1
659 || cy + sr >= height as i32 - 1
660 {
661 continue;
662 }
663 let x0 = (cx - sr) as usize;
664 let y0 = (cy - sr) as usize;
665 let x1 = (cx + sr) as usize;
666 let y1 = (cy + sr) as usize;
667 let stamp_w = x1 - x0 + 1;
668 let mut stamp = Vec::with_capacity(stamp_w * (y1 - y0 + 1));
669 for sy in y0..=y1 {
670 for sx in x0..=x1 {
671 let bg = bg_map.map_or(background, |m| m[sy * width + sx]);
672 stamp.push(lum[sy * width + sx] - bg);
673 }
674 }
675 let rel_cx = star.x - x0 as f32;
676 let rel_cy = star.y - y0 as f32;
677 let sigma = metrics::estimate_sigma_halfmax(&stamp, stamp_w, rel_cx, rel_cy);
678 let fwhm = sigma * 2.3548;
679 if fwhm > 1.0 && fwhm < 20.0 {
680 fwhm_vals.push(fwhm);
681 }
682 }
683
684 if fwhm_vals.len() < 3 {
685 return 0.0;
686 }
687 find_median(&mut fwhm_vals)
688}
689
690fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
696 let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
697 (0..height)
698 .flat_map(|y| {
699 (0..width).map(move |x| {
700 let parity = (x + y) & 1;
701 if green_at_even { parity == 0 } else { parity == 1 }
702 })
703 })
704 .collect()
705}
706
707fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
709 use rayon::prelude::*;
710
711 let plane_size = width * height;
712 let r = &data[..plane_size];
713 let g = &data[plane_size..2 * plane_size];
714 let b = &data[2 * plane_size..3 * plane_size];
715
716 let mut lum = vec![0.0_f32; plane_size];
717 const CHUNK: usize = 8192;
718 lum.par_chunks_mut(CHUNK)
719 .enumerate()
720 .for_each(|(ci, chunk)| {
721 let off = ci * CHUNK;
722 for (i, dst) in chunk.iter_mut().enumerate() {
723 let idx = off + i;
724 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
725 }
726 });
727 lum
728}