1use crate::types::ImageBuffer;
4
5#[derive(Debug, Clone, Copy)]
9pub struct Region {
10 pub x0: usize,
12 pub x1: usize,
14 pub y0: usize,
16 pub y1: usize,
18}
19
20impl Region {
21 #[must_use]
23 pub fn whole(img: &ImageBuffer) -> Self {
24 Self {
25 x0: 0,
26 x1: img.width - 1,
27 y0: 0,
28 y1: img.height - 1,
29 }
30 }
31
32 #[must_use]
35 pub fn inset(img: &ImageBuffer) -> Self {
36 Self {
37 x0: 1,
38 x1: img.width - 2,
39 y0: 1,
40 y1: img.height - 2,
41 }
42 }
43
44 #[must_use]
46 pub fn with_rows(self, y0: usize, y1: usize) -> Self {
47 Self { y0, y1, ..self }
48 }
49
50 #[must_use]
52 pub fn rows(&self) -> usize {
53 self.y1.saturating_sub(self.y0) + 1
54 }
55}
56
57const HIST_SIZE: usize = 65536; fn build_histogram(img: &ImageBuffer, r: Region, upper_limit: usize) -> Vec<u32> {
61 let (x0, x1, y0, y1) = (r.x0, r.x1, r.y0, r.y1);
62 let cap = upper_limit.min(HIST_SIZE - 1);
63
64 let rows = y1.saturating_sub(y0) + 1;
67 let threads = crate::max_threads().clamp(1, 32);
68 let n_bands = if rows < 256 || (x1 - x0 + 1) * rows < 1 << 20 {
70 1
71 } else {
72 threads.min(rows / 64).max(1)
73 };
74
75 if n_bands == 1 {
76 return histogram_rows(img, x0, x1, y0, y1, cap);
77 }
78
79 let band_rows = rows.div_ceil(n_bands);
80 let parts: Vec<Vec<u32>> = std::thread::scope(|scope| {
81 let handles: Vec<_> = (0..n_bands)
82 .map(|b| {
83 let by0 = y0 + b * band_rows;
84 let by1 = (by0 + band_rows - 1).min(y1);
85 scope.spawn(move || {
86 if by0 > y1 {
87 Vec::new()
88 } else {
89 histogram_rows(img, x0, x1, by0, by1, cap)
90 }
91 })
92 })
93 .collect();
94 handles
95 .into_iter()
96 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
100 .collect()
101 });
102
103 let mut hist = vec![0u32; cap + 1];
104 for part in parts {
105 for (h, p) in hist.iter_mut().zip(part.iter()) {
106 *h += *p;
107 }
108 }
109 hist
110}
111
112fn histogram_rows(
114 img: &ImageBuffer,
115 x0: usize,
116 x1: usize,
117 y0: usize,
118 y1: usize,
119 cap: usize,
120) -> Vec<u32> {
121 let mut hist = vec![0u32; cap + 1];
122 for y in y0..=y1 {
123 let row = y * img.width;
124 for x in x0..=x1 {
125 let raw = img.data[row + x];
126 if raw.is_finite() {
129 let v = raw as usize;
130 if v <= cap {
131 hist[v] += 1;
132 }
133 }
134 }
135 }
136 hist
137}
138
139#[must_use]
148pub fn sigma_clipped_mean_from_histogram(
149 img: &ImageBuffer,
150 region: Region,
151 upper_limit: usize,
152 max_iterations: usize,
153 convergence_threshold: f64,
154) -> (f64, f64) {
155 const SIGMA_HIGH: f64 = 2.0;
156
157 let hist = build_histogram(img, region, upper_limit);
158 let hist_len = hist.len();
159
160 let mut mean = 0.0f64;
161 let mut stdev = 0.0f64;
162 let mut lo = 0usize;
163 let mut hi = hist_len - 1;
164
165 for iter in 0..max_iterations {
166 let prev_mean = mean;
167 let prev_stdev = stdev;
168
169 let mut sum = 0.0f64;
170 let mut sum_sq = 0.0f64;
171 let mut total = 0u64;
172
173 for (i, &bin) in hist.iter().enumerate().take(hi + 1).skip(lo) {
174 let cnt = bin as u64;
175 if cnt > 0 {
176 let v = i as f64;
177 sum += v * cnt as f64;
178 sum_sq += v * v * cnt as f64;
179 total += cnt;
180 }
181 }
182
183 if total == 0 {
184 break;
185 }
186
187 mean = sum / total as f64;
188 let variance = if total > 1 {
189 let v = (sum_sq - sum * sum / total as f64) / (total as f64 - 1.0);
190 v.max(0.0)
191 } else {
192 0.0
193 };
194 stdev = variance.sqrt();
195
196 if stdev > 0.0 {
197 lo = 0;
199 hi = (upper_limit)
200 .min((mean + SIGMA_HIGH * stdev).round() as usize)
201 .min(hist_len - 1);
202 }
203
204 if iter > 0
205 && (mean - prev_mean).abs() < convergence_threshold
206 && (stdev - prev_stdev).abs() < convergence_threshold
207 {
208 break;
209 }
210 }
211
212 (mean, stdev)
213}
214
215#[derive(Debug, Clone)]
217pub struct Background {
218 pub mean: f64,
220 pub noise: f64,
222 pub star_level: f64,
224 pub star_level2: f64,
226}
227
228#[must_use]
234pub fn get_background(img: &ImageBuffer, max_stars: usize) -> Background {
235 let width = img.width;
236 let height = img.height;
237 if width == 0 || height == 0 || img.data.is_empty() {
238 return Background {
239 mean: 0.0,
240 noise: 0.0,
241 star_level: 0.0,
242 star_level2: 0.0,
243 };
244 }
245
246 let hist = build_histogram(img, Region::whole(img), 65001);
248
249 let total_pixels = (width * height) as u64;
251 let mean_value = {
252 let sum: u64 = hist
253 .iter()
254 .enumerate()
255 .map(|(i, &c)| i as u64 * c as u64)
256 .sum();
257 (sum / total_pixels.max(1)) as usize
258 };
259
260 let background = if mean_value == 0 {
261 0.0
262 } else {
263 let mut background = img.data[0] as f64;
264 let mut peak_count = 0u32;
265 for (i, &bin) in hist.iter().enumerate().take(mean_value + 1).skip(1) {
266 if bin > peak_count {
267 peak_count = bin;
268 background = i as f64;
269 }
270 }
271 if mean_value as f64 > 1.5 * background {
273 background = mean_value as f64;
274 }
275 background
276 };
277
278 let step_size = ((height as f64 / 71.0).round() as usize).max(1);
280 let step_size = if step_size.is_multiple_of(2) {
282 step_size + 1
283 } else {
284 step_size
285 };
286
287 let mut sd = 1e9f64;
288 let mut iterations = 0usize;
289 loop {
290 let sd_old = sd;
291 let mut sum_sq = 0.0f64;
292 let mut counter = 0u64;
293
294 let mut x = 15usize;
295 while x <= width.saturating_sub(16) {
296 let mut y = 15usize;
297 while y <= height.saturating_sub(16) {
298 let value = img.get(x, y) as f64;
299 if value < background * 2.0
301 && value != 0.0
302 && (iterations == 0 || (value - background).abs() <= 3.0 * sd_old)
303 {
304 sum_sq += (value - background).powi(2);
305 counter += 1;
306 }
307 y += step_size;
308 }
309 x += step_size;
310 }
311
312 sd = if counter > 0 {
313 (sum_sq / counter as f64).sqrt()
314 } else {
315 0.0
316 };
317 iterations += 1;
318
319 if (sd_old - sd).abs() < 0.05 * sd || iterations >= 7 {
320 break;
321 }
322 }
323 let noise = sd;
324
325 let max_range = 65001usize;
327 let factor = 6 * max_stars;
328 let factor2 = 24 * max_stars;
329
330 let mut above = 0usize;
331 let mut star_level_raw = 0.0f64;
332 let mut star_level2_raw = 0.0f64;
333 let mut i = max_range;
334
335 while star_level_raw == 0.0 && i > (background + 1.0) as usize {
336 i -= 1;
337 above += hist[i.min(hist.len() - 1)] as usize;
338 if above >= factor {
339 star_level_raw = i as f64;
340 }
341 }
342 while star_level2_raw == 0.0 && i > (background + 1.0) as usize {
343 i -= 1;
344 above += hist[i.min(hist.len() - 1)] as usize;
345 if above >= factor2 {
346 star_level2_raw = i as f64;
347 }
348 }
349
350 let min_level = (3.5 * noise).max(1.0);
351 let star_level = min_level.max((star_level_raw - background - 1.0).max(0.0));
352 let star_level2 = min_level.max((star_level2_raw - background - 1.0).max(0.0));
353
354 Background {
355 mean: background,
356 noise,
357 star_level,
358 star_level2,
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn make_flat_image(width: usize, height: usize, value: f32) -> ImageBuffer {
367 ImageBuffer {
368 data: vec![value; width * height],
369 width,
370 height,
371 }
372 }
373
374 fn make_image_with_noise(width: usize, height: usize, bg: f32, noise_amp: f32) -> ImageBuffer {
375 let mut data = vec![0f32; width * height];
376 for (i, v) in data.iter_mut().enumerate() {
378 let noise = ((i * 2654435769 + 1234567) & 0xFFFF) as f32 / 65535.0 * noise_amp;
379 *v = bg + noise - noise_amp / 2.0;
380 }
381 ImageBuffer {
382 data,
383 width,
384 height,
385 }
386 }
387
388 #[test]
389 fn flat_image_background() {
390 let img = make_flat_image(100, 100, 1000.0);
391 let bg = get_background(&img, 500);
392 assert!((bg.mean - 1000.0).abs() < 5.0, "background = {}", bg.mean);
394 assert!(bg.noise < 1.0, "flat image noise = {}", bg.noise);
395 }
396
397 #[test]
398 fn sigma_clip_converges() {
399 let img = make_image_with_noise(200, 200, 5000.0, 200.0);
400 let (mean, stdev) = sigma_clipped_mean_from_histogram(
401 &img,
402 Region {
403 x0: 0,
404 x1: 199,
405 y0: 0,
406 y1: 199,
407 },
408 65500,
409 6,
410 0.1,
411 );
412 assert!((mean - 5000.0).abs() < 200.0, "mean = {mean}");
414 assert!(stdev > 0.0, "stdev should be positive, got {stdev}");
415 }
416
417 #[test]
418 fn star_levels_increase_with_stars() {
419 let mut img = make_image_with_noise(200, 200, 1000.0, 50.0);
421 for i in 0..20 {
423 let x = 10 + i * 9;
424 let y = 10 + i * 9;
425 if x < 200 && y < 200 {
426 img.data[y * 200 + x] = 20000.0;
427 }
428 }
429 let bg = get_background(&img, 500);
430 assert!(bg.star_level > 0.0, "star_level = {}", bg.star_level);
432 }
433
434 fn gaussian_image(width: usize, height: usize, bg: f64, sigma: f64) -> ImageBuffer {
436 let mut rng = crate::test_support::Rng::new(9);
437 ImageBuffer {
438 data: (0..width * height)
439 .map(|_| (bg + sigma * rng.gauss()) as f32)
440 .collect(),
441 width,
442 height,
443 }
444 }
445
446 #[test]
450 fn large_frames_estimate_background_and_noise() {
451 let mut img = gaussian_image(1024, 1100, 1500.0, 20.0);
452 for i in (0..img.data.len()).step_by(4099) {
453 img.data[i] = f32::NAN;
454 }
455 let bg = get_background(&img, 500);
456 assert!((bg.mean - 1500.0).abs() <= 2.0, "background {}", bg.mean);
457 assert!((bg.noise - 20.0).abs() < 2.0, "noise {}", bg.noise);
458 assert!(bg.star_level >= 0.0 && bg.star_level2 >= 0.0);
459
460 let whole = sigma_clipped_mean_from_histogram(&img, Region::whole(&img), 65500, 10, 0.01);
461 let small = gaussian_image(200, 200, 1500.0, 20.0);
462 let part =
463 sigma_clipped_mean_from_histogram(&small, Region::whole(&small), 65500, 10, 0.01);
464 assert!((whole.0 - part.0).abs() < 1.0, "{whole:?} vs {part:?}");
465 assert!((whole.1 - part.1).abs() < 1.0, "{whole:?} vs {part:?}");
466 }
467
468 #[test]
469 fn upper_limit_and_empty_regions() {
470 let img = gaussian_image(100, 100, 1000.0, 10.0);
471 let (mean, sd) = sigma_clipped_mean_from_histogram(&img, Region::whole(&img), 500, 5, 0.1);
473 assert_eq!((mean, sd), (0.0, 0.0));
474 assert_eq!(get_background(&ImageBuffer::new(0, 0), 10).noise, 0.0);
475 let r = Region::inset(&img).with_rows(10, 19);
476 assert_eq!((r.x0, r.x1, r.rows()), (1, 98, 10));
477 }
478}