1use crate::detection::background::{Background, get_background};
20use crate::detection::stars::{measure_star_with_flux, median_f64};
21use crate::types::ImageBuffer;
22
23#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct MeasuredStar {
26 pub x: f64,
28 pub y: f64,
30 pub hfd: f64,
32 pub snr: f64,
34 pub flux: f64,
36}
37
38#[derive(Debug, Clone)]
40pub struct Analysis {
41 pub stars: Vec<MeasuredStar>,
43 pub background: Background,
45}
46
47impl Analysis {
48 #[must_use]
50 pub fn hfd_median(&self) -> Option<f64> {
51 if self.stars.is_empty() {
52 return None;
53 }
54 let mut hfds: Vec<f64> = self.stars.iter().map(|s| s.hfd).collect();
55 Some(median_f64(&mut hfds))
56 }
57}
58
59#[must_use]
75pub fn analyse_image(img: &ImageBuffer, snr_min: f64, max_stars: usize) -> Analysis {
76 let background = get_background(img, max_stars);
77 let (w, h) = (img.width, img.height);
78 if w < 3 || h < 3 || img.data.len() < w * h {
79 return Analysis {
80 stars: Vec::new(),
81 background,
82 };
83 }
84
85 let noise = background.noise;
86 let mut retries = 4u8;
87 let stars = loop {
88 let mut level = background.star_level;
89 if retries == 4 && background.star_level <= 30.0 * noise {
90 retries = 3;
91 }
92 if retries == 3 {
93 if background.star_level2 > 30.0 * noise {
94 level = background.star_level2;
95 } else {
96 retries = 2;
97 }
98 }
99 if retries == 2 {
100 level = 30.0 * noise;
101 if snr_min >= 30.0 {
102 retries = 1;
103 }
104 }
105 if retries == 1 {
106 level = snr_min.max(7.0) * noise;
107 }
108
109 let found = scan(img, &background, level, snr_min);
110 retries -= 1;
111 if found.len() >= max_stars || retries == 0 {
112 break found;
113 }
114 };
115
116 Analysis { stars, background }
117}
118
119fn scan(img: &ImageBuffer, bg: &Background, level: f64, snr_min: f64) -> Vec<MeasuredStar> {
122 let (w, h) = (img.width, img.height);
123 let detect_abs = bg.mean + level;
124 let hot_abs = bg.mean + 4.0 * bg.noise;
125 let mut taken = vec![false; w * h];
127 let mut out = Vec::new();
128
129 for fy in 1..h - 1 {
130 let row = fy * w;
131 for fx in 1..w - 1 {
132 if (img.data[row + fx] as f64) <= detect_abs || taken[row + fx] {
133 continue;
134 }
135 let lit = [row + fx - 1, row + fx + 1, row - w + fx, row + w + fx]
138 .iter()
139 .filter(|&&i| img.data[i] as f64 > hot_abs)
140 .count();
141 if lit < 2 {
142 continue;
143 }
144
145 let Some((star, flux)) = measure_star_with_flux(img, fx as i32, fy as i32) else {
146 continue;
147 };
148 if !(star.hfd <= 30.0 && star.snr > snr_min && star.hfd > 0.8) {
149 continue;
150 }
151 let xci = star.x.round() as i64;
152 let yci = star.y.round() as i64;
153 if xci >= 0
154 && yci >= 0
155 && (xci as usize) < w
156 && (yci as usize) < h
157 && taken[yci as usize * w + xci as usize]
158 {
159 continue; }
161
162 let radius = (3.0 * star.hfd).round() as i64;
164 for n in -radius..=radius {
165 for m in -radius..=radius {
166 let (xi, yi) = (xci + m, yci + n);
167 if xi >= 0
168 && yi >= 0
169 && (xi as usize) < w
170 && (yi as usize) < h
171 && m * m + n * n <= radius * radius
172 {
173 taken[yi as usize * w + xi as usize] = true;
174 }
175 }
176 }
177 out.push(MeasuredStar {
178 x: star.x,
179 y: star.y,
180 hfd: star.hfd,
181 snr: star.snr,
182 flux,
183 });
184 }
185 }
186 out
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn add_star(img: &mut ImageBuffer, cx: f64, cy: f64, sigma: f64, peak: f64) {
195 let rs = (5.0 * sigma).ceil() as i64;
196 for dy in -rs..=rs {
197 for dx in -rs..=rs {
198 let (x, y) = (cx.round() as i64 + dx, cy.round() as i64 + dy);
199 if x < 0 || y < 0 || x as usize >= img.width || y as usize >= img.height {
200 continue;
201 }
202 let r2 = (x as f64 - cx).powi(2) + (y as f64 - cy).powi(2);
203 img.data[y as usize * img.width + x as usize] +=
204 (peak * (-r2 / (2.0 * sigma * sigma)).exp()) as f32;
205 }
206 }
207 }
208
209 fn noisy(w: usize, h: usize, bg: f64, sigma: f64) -> ImageBuffer {
210 let mut rng = crate::test_support::Rng::new(3);
211 ImageBuffer {
212 data: (0..w * h)
213 .map(|_| (bg + sigma * rng.gauss()) as f32)
214 .collect(),
215 width: w,
216 height: h,
217 }
218 }
219
220 #[test]
221 fn finds_every_star_with_its_hfd_and_position() {
222 let mut img = noisy(400, 300, 1000.0, 10.0);
223 let planted = [
224 (50.3, 60.7),
225 (200.0, 150.0),
226 (330.6, 40.2),
227 (120.0, 250.5),
228 (300.0, 220.0),
229 ];
230 for &(x, y) in &planted {
231 add_star(&mut img, x, y, 1.5, 5000.0);
232 }
233 let a = analyse_image(&img, 30.0, 500);
234 assert_eq!(a.stars.len(), planted.len(), "{:?}", a.stars);
235 for s in &a.stars {
236 assert!(
237 planted
238 .iter()
239 .any(|&(x, y)| (s.x - x).abs() < 0.2 && (s.y - y).abs() < 0.2),
240 "{s:?} is not a planted star"
241 );
242 assert!((s.hfd - 2.35 * 1.5).abs() < 0.6, "hfd {}", s.hfd);
244 assert!(s.snr > 30.0 && s.flux > 0.0);
245 }
246 assert!(a.stars.windows(2).all(|p| p[0].y.round() <= p[1].y.round()));
248 let median = a.hfd_median().unwrap();
249 assert!((median - 2.35 * 1.5).abs() < 0.6, "median {median}");
250 }
251
252 #[test]
253 fn snr_min_sets_the_faintest_star_reported() {
254 let mut img = noisy(300, 300, 1000.0, 10.0);
255 add_star(&mut img, 80.0, 80.0, 1.5, 5000.0);
256 add_star(&mut img, 200.0, 200.0, 2.0, 120.0);
259 let strict = analyse_image(&img, 30.0, 500);
260 assert_eq!(strict.stars.len(), 1);
261 let loose = analyse_image(&img, 5.0, 500);
262 assert_eq!(loose.stars.len(), 2, "{:?}", loose.stars);
263 }
264
265 #[test]
266 fn a_blank_frame_has_no_stars_and_no_median() {
267 let img = noisy(200, 200, 1000.0, 10.0);
268 let a = analyse_image(&img, 30.0, 500);
269 assert!(a.stars.is_empty());
270 assert_eq!(a.hfd_median(), None);
271 assert!(
273 analyse_image(&ImageBuffer::new(2, 2), 30.0, 500)
274 .stars
275 .is_empty()
276 );
277 }
278
279 #[test]
280 fn a_hot_pixel_is_not_a_star() {
281 let mut img = noisy(100, 100, 1000.0, 10.0);
282 img.data[50 * 100 + 50] = 60000.0;
283 assert!(analyse_image(&img, 10.0, 500).stars.is_empty());
284 }
285}