1mod background;
4mod detection;
5mod fitting;
6mod metrics;
7mod snr;
8
9use std::path::Path;
10use std::sync::Arc;
11
12use anyhow::{Context, Result};
13
14use crate::formats;
15use crate::processing::color::u16_to_f32;
16use crate::processing::debayer;
17use crate::processing::stretch::find_median;
18use crate::types::{BayerPattern, ImageMetadata, PixelData};
19
20use detection::DetectionParams;
21
22pub struct StarMetrics {
24 pub x: f32,
26 pub y: f32,
28 pub peak: f32,
30 pub flux: f32,
32 pub fwhm_x: f32,
34 pub fwhm_y: f32,
36 pub fwhm: f32,
38 pub eccentricity: f32,
40 pub snr: f32,
42 pub hfr: f32,
44 pub theta: f32,
48}
49
50pub struct AnalysisResult {
52 pub width: usize,
54 pub height: usize,
56 pub source_channels: usize,
58 pub background: f32,
60 pub noise: f32,
62 pub detection_threshold: f32,
64 pub stars_detected: usize,
66 pub stars: Vec<StarMetrics>,
68 pub median_fwhm: f32,
70 pub median_eccentricity: f32,
72 pub median_snr: f32,
74 pub median_hfr: f32,
76 pub snr_db: f32,
78 pub snr_weight: f32,
80 pub psf_signal: f32,
82 pub trail_r_squared: f32,
86 pub possibly_trailed: bool,
89}
90
91pub struct AnalysisConfig {
93 detection_sigma: f32,
94 min_star_area: usize,
95 max_star_area: usize,
96 saturation_fraction: f32,
97 max_stars: usize,
98 use_gaussian_fit: bool,
99 background_mesh_size: Option<usize>,
100 apply_debayer: bool,
101 max_eccentricity: f32,
102 trail_r_squared_threshold: f32,
103}
104
105pub struct ImageAnalyzer {
107 config: AnalysisConfig,
108 thread_pool: Option<Arc<rayon::ThreadPool>>,
109}
110
111impl ImageAnalyzer {
112 pub fn new() -> Self {
113 ImageAnalyzer {
114 config: AnalysisConfig {
115 detection_sigma: 5.0,
116 min_star_area: 5,
117 max_star_area: 2000,
118 saturation_fraction: 0.95,
119 max_stars: 200,
120 use_gaussian_fit: true,
121 background_mesh_size: None,
122 apply_debayer: true,
123 max_eccentricity: 1.0,
124 trail_r_squared_threshold: 0.5,
125 },
126 thread_pool: None,
127 }
128 }
129
130 pub fn with_detection_sigma(mut self, sigma: f32) -> Self {
132 self.config.detection_sigma = sigma.max(1.0);
133 self
134 }
135
136 pub fn with_min_star_area(mut self, area: usize) -> Self {
138 self.config.min_star_area = area.max(1);
139 self
140 }
141
142 pub fn with_max_star_area(mut self, area: usize) -> Self {
144 self.config.max_star_area = area;
145 self
146 }
147
148 pub fn with_saturation_fraction(mut self, frac: f32) -> Self {
150 self.config.saturation_fraction = frac.clamp(0.5, 1.0);
151 self
152 }
153
154 pub fn with_max_stars(mut self, n: usize) -> Self {
156 self.config.max_stars = n.max(1);
157 self
158 }
159
160 pub fn without_gaussian_fit(mut self) -> Self {
162 self.config.use_gaussian_fit = false;
163 self
164 }
165
166 pub fn with_background_mesh(mut self, cell_size: usize) -> Self {
168 self.config.background_mesh_size = Some(cell_size.max(16));
169 self
170 }
171
172 pub fn without_debayer(mut self) -> Self {
174 self.config.apply_debayer = false;
175 self
176 }
177
178 pub fn with_max_eccentricity(mut self, ecc: f32) -> Self {
181 self.config.max_eccentricity = ecc.clamp(0.0, 1.0);
182 self
183 }
184
185 pub fn with_trail_threshold(mut self, threshold: f32) -> Self {
189 self.config.trail_r_squared_threshold = threshold.clamp(0.0, 1.0);
190 self
191 }
192
193 pub fn with_thread_pool(mut self, pool: Arc<rayon::ThreadPool>) -> Self {
195 self.thread_pool = Some(pool);
196 self
197 }
198
199 pub fn analyze<P: AsRef<Path>>(&self, path: P) -> Result<AnalysisResult> {
201 let path = path.as_ref();
202 match &self.thread_pool {
203 Some(pool) => pool.install(|| self.analyze_impl(path)),
204 None => self.analyze_impl(path),
205 }
206 }
207
208 pub fn analyze_data(
215 &self,
216 data: &[f32],
217 width: usize,
218 height: usize,
219 channels: usize,
220 ) -> Result<AnalysisResult> {
221 match &self.thread_pool {
222 Some(pool) => pool.install(|| {
223 self.run_analysis(data, width, height, channels, None)
224 }),
225 None => self.run_analysis(data, width, height, channels, None),
226 }
227 }
228
229 pub fn analyze_raw(
234 &self,
235 meta: &ImageMetadata,
236 pixels: &PixelData,
237 ) -> Result<AnalysisResult> {
238 match &self.thread_pool {
239 Some(pool) => pool.install(|| self.analyze_raw_impl(meta, pixels)),
240 None => self.analyze_raw_impl(meta, pixels),
241 }
242 }
243
244 fn analyze_raw_impl(
245 &self,
246 meta: &ImageMetadata,
247 pixels: &PixelData,
248 ) -> Result<AnalysisResult> {
249 let f32_data = match pixels {
250 PixelData::Float32(d) => std::borrow::Cow::Borrowed(d.as_slice()),
251 PixelData::Uint16(d) => std::borrow::Cow::Owned(u16_to_f32(d)),
252 };
253
254 let mut data = f32_data.into_owned();
255 let width = meta.width;
256 let height = meta.height;
257 let channels = meta.channels;
258
259 let green_mask = if self.config.apply_debayer
260 && meta.bayer_pattern != BayerPattern::None
261 && channels == 1
262 {
263 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
264 Some(build_green_mask(width, height, meta.bayer_pattern))
265 } else {
266 None
267 };
268
269 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
270 }
271
272 fn analyze_impl(&self, path: &Path) -> Result<AnalysisResult> {
273 let (meta, pixel_data) =
274 formats::read_image(path).context("Failed to read image for analysis")?;
275
276 let f32_data = match pixel_data {
278 PixelData::Float32(d) => d,
279 PixelData::Uint16(d) => u16_to_f32(&d),
280 };
281
282 let mut data = f32_data;
283 let width = meta.width;
284 let height = meta.height;
285 let channels = meta.channels;
286
287 let green_mask = if self.config.apply_debayer
290 && meta.bayer_pattern != BayerPattern::None
291 && channels == 1
292 {
293 data = debayer::interpolate_green_f32(&data, width, height, meta.bayer_pattern);
294 Some(build_green_mask(width, height, meta.bayer_pattern))
296 } else {
297 None
298 };
299
300 self.run_analysis(&data, width, height, channels, green_mask.as_deref())
301 }
302
303 fn run_analysis(
304 &self,
305 data: &[f32],
306 width: usize,
307 height: usize,
308 channels: usize,
309 green_mask: Option<&[bool]>,
310 ) -> Result<AnalysisResult> {
311 let lum = if channels == 3 {
313 extract_luminance(data, width, height)
314 } else {
315 data[..width * height].to_vec()
316 };
317
318 let bg_result = if let Some(cell_size) = self.config.background_mesh_size {
320 background::estimate_background_mesh(&lum, width, height, cell_size)
321 } else {
322 background::estimate_background(&lum, width, height)
323 };
324
325 let bg_map_ref = bg_result.background_map.as_deref();
326
327 let det_params = DetectionParams {
329 detection_sigma: self.config.detection_sigma,
330 min_star_area: self.config.min_star_area,
331 max_star_area: self.config.max_star_area,
332 saturation_limit: self.config.saturation_fraction * 65535.0,
333 max_stars: self.config.max_stars,
334 };
335
336 let detected = detection::detect_stars(
337 &lum,
338 width,
339 height,
340 bg_result.background,
341 bg_result.noise,
342 bg_map_ref,
343 &det_params,
344 );
345
346 let stars_detected = detected.len();
347 let detection_threshold = self.config.detection_sigma * bg_result.noise;
348
349 let (trail_r_squared, possibly_trailed) = if detected.len() >= 5 {
363 let n = detected.len();
364 let (sum_cos, sum_sin) =
365 detected
366 .iter()
367 .fold((0.0f64, 0.0f64), |(sc, ss), s| {
368 let a = 2.0 * s.theta as f64;
369 (sc + a.cos(), ss + a.sin())
370 });
371 let r_sq = (sum_cos * sum_cos + sum_sin * sum_sin) / (n as f64 * n as f64);
372 let p = (-(n as f64) * r_sq).exp();
373
374 let mut eccs: Vec<f32> = detected.iter().map(|s| s.eccentricity).collect();
375 eccs.sort_unstable_by(|a, b| a.total_cmp(b));
376 let median_ecc = eccs[eccs.len() / 2];
377
378 let threshold = self.config.trail_r_squared_threshold as f64;
379 let trailed = (r_sq > threshold && p < 0.01)
380 || (median_ecc > 0.6 && p < 0.05);
381
382 (r_sq as f32, trailed)
383 } else {
384 (0.0, false)
385 };
386
387 let zero_result = || {
388 Ok(AnalysisResult {
389 width,
390 height,
391 source_channels: channels,
392 background: bg_result.background,
393 noise: bg_result.noise,
394 detection_threshold,
395 stars_detected,
396 stars: Vec::new(),
397 median_fwhm: 0.0,
398 median_eccentricity: 0.0,
399 median_snr: 0.0,
400 median_hfr: 0.0,
401 snr_db: snr::compute_snr_db(&lum, bg_result.noise),
402 snr_weight: snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise),
403 psf_signal: 0.0,
404 trail_r_squared,
405 possibly_trailed,
406 })
407 };
408
409 if stars_detected == 0 {
410 return zero_result();
411 }
412
413 let mut measured = metrics::measure_stars(
415 &lum,
416 width,
417 height,
418 &detected,
419 bg_result.background,
420 bg_map_ref,
421 self.config.use_gaussian_fit,
422 green_mask,
423 );
424
425 if measured.is_empty() {
426 return zero_result();
427 }
428
429 measured.retain(|s| s.eccentricity <= self.config.max_eccentricity);
431
432 if measured.is_empty() {
433 return zero_result();
434 }
435
436 let mut fwhm_vals: Vec<f32> = measured.iter().map(|s| s.fwhm).collect();
438 let median_fwhm = find_median(&mut fwhm_vals);
439
440 snr::compute_star_snr(&lum, width, height, &mut measured, median_fwhm);
442
443 let mut ecc_vals: Vec<f32> = measured.iter().map(|s| s.eccentricity).collect();
445 let mut snr_vals: Vec<f32> = measured.iter().map(|s| s.snr).collect();
446 let mut hfr_vals: Vec<f32> = measured.iter().map(|s| s.hfr).collect();
447
448 let median_eccentricity = find_median(&mut ecc_vals);
449 let median_snr = find_median(&mut snr_vals);
450 let median_hfr = find_median(&mut hfr_vals);
451
452 let snr_db = snr::compute_snr_db(&lum, bg_result.noise);
453 let snr_weight = snr::compute_snr_weight(&lum, bg_result.background, bg_result.noise);
454 let psf_signal = snr::compute_psf_signal(&measured, bg_result.noise);
455
456 let stars: Vec<StarMetrics> = measured
458 .into_iter()
459 .map(|m| StarMetrics {
460 x: m.x,
461 y: m.y,
462 peak: m.peak,
463 flux: m.flux,
464 fwhm_x: m.fwhm_x,
465 fwhm_y: m.fwhm_y,
466 fwhm: m.fwhm,
467 eccentricity: m.eccentricity,
468 snr: m.snr,
469 hfr: m.hfr,
470 theta: m.theta,
471 })
472 .collect();
473
474 Ok(AnalysisResult {
475 width,
476 height,
477 source_channels: channels,
478 background: bg_result.background,
479 noise: bg_result.noise,
480 detection_threshold,
481 stars_detected,
482 stars,
483 median_fwhm,
484 median_eccentricity,
485 median_snr,
486 median_hfr,
487 snr_db,
488 snr_weight,
489 psf_signal,
490 trail_r_squared,
491 possibly_trailed,
492 })
493 }
494}
495
496impl Default for ImageAnalyzer {
497 fn default() -> Self {
498 Self::new()
499 }
500}
501
502fn build_green_mask(width: usize, height: usize, pattern: BayerPattern) -> Vec<bool> {
508 let green_at_even = matches!(pattern, BayerPattern::Gbrg | BayerPattern::Grbg);
509 (0..height)
510 .flat_map(|y| {
511 (0..width).map(move |x| {
512 let parity = (x + y) & 1;
513 if green_at_even { parity == 0 } else { parity == 1 }
514 })
515 })
516 .collect()
517}
518
519fn extract_luminance(data: &[f32], width: usize, height: usize) -> Vec<f32> {
521 use rayon::prelude::*;
522
523 let plane_size = width * height;
524 let r = &data[..plane_size];
525 let g = &data[plane_size..2 * plane_size];
526 let b = &data[2 * plane_size..3 * plane_size];
527
528 let mut lum = vec![0.0_f32; plane_size];
529 const CHUNK: usize = 8192;
530 lum.par_chunks_mut(CHUNK)
531 .enumerate()
532 .for_each(|(ci, chunk)| {
533 let off = ci * CHUNK;
534 for (i, dst) in chunk.iter_mut().enumerate() {
535 let idx = off + i;
536 *dst = 0.2126 * r[idx] + 0.7152 * g[idx] + 0.0722 * b[idx];
537 }
538 });
539 lum
540}