Skip to main content

arcsec_core/detection/
background.rs

1//! Background estimation for star detection.
2
3use crate::types::ImageBuffer;
4
5/// An inclusive pixel rectangle. Grouping the four bounds keeps the scan signatures
6/// readable — they were long enough that clippy flagged them, and the bounds always
7/// travel together anyway.
8#[derive(Debug, Clone, Copy)]
9pub struct Region {
10    /// First column.
11    pub x0: usize,
12    /// Last column (inclusive).
13    pub x1: usize,
14    /// First row.
15    pub y0: usize,
16    /// Last row (inclusive).
17    pub y1: usize,
18}
19
20impl Region {
21    /// The whole frame. The image must be at least 1×1.
22    #[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    /// The frame minus a one-pixel border, which is what the star scan wants: the
33    /// hot-pixel test reads a neighbour on each side. The image must be at least 3×3.
34    #[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    /// The same rectangle with a different row range.
45    #[must_use]
46    pub fn with_rows(self, y0: usize, y1: usize) -> Self {
47        Self { y0, y1, ..self }
48    }
49
50    /// Number of rows covered.
51    #[must_use]
52    pub fn rows(&self) -> usize {
53        self.y1.saturating_sub(self.y0) + 1
54    }
55}
56
57const HIST_SIZE: usize = 65536; // 16-bit pixel values
58
59/// Build a histogram of pixel values in a sub-region of the image.
60fn 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    // Histogram addition is associative and exact, so accumulate per-thread and sum:
65    // this is a full pass over the frame and was ~8% of a typical solve.
66    let rows = y1.saturating_sub(y0) + 1;
67    let threads = crate::max_threads().clamp(1, 32);
68    // Below this the thread setup costs more than the scan.
69    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            // A dead band must not become an empty histogram: the merge below
97            // would zip against nothing and yield a plausible but wrong
98            // background, so every later stage would be quietly miscalibrated.
99            .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
112/// Histogram of one row range.
113fn 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            // `as usize` saturates NaN to 0 and negatives to 0, which would bias
127            // the background low; skip anything non-finite outright.
128            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/// Sigma-clipped mean and standard deviation from a histogram sub-region.
140///
141/// Values above `mean + 2σ` are clipped on each iteration; the lower bound is held
142/// at 0, so nothing is clipped from below. Pixel values above `upper_limit` (and
143/// above 65535) are ignored. Iterates until both the mean and σ change by less
144/// than `convergence_threshold`, or `max_iterations` is reached.
145///
146/// Returns `(mean, stdev)`.
147#[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            // The lower clip bound is deliberately held at 0: no low-side clipping.
198            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/// Result of background analysis.
216#[derive(Debug, Clone)]
217pub struct Background {
218    /// Modal background value (peak of histogram).
219    pub mean: f64,
220    /// Noise standard deviation (sigma-clipped).
221    pub noise: f64,
222    /// Detection threshold for bright/small stars (HFD ~2.25 px).
223    pub star_level: f64,
224    /// Detection threshold for faint/large stars (HFD ~4.5 px).
225    pub star_level2: f64,
226}
227
228/// Analyse image background, noise, and star detection levels.
229///
230/// `max_stars`: number of stars expected (empirical factor for `star_level`).
231///
232/// An empty image yields an all-zero `Background`.
233#[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    // Build full histogram (0..65535)
247    let hist = build_histogram(img, Region::whole(img), 65001);
248
249    // --- Find background: peak of histogram ---
250    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 histogram mean is > 1.5× modal peak, use mean instead
272        if mean_value as f64 > 1.5 * background {
273            background = mean_value as f64;
274        }
275        background
276    };
277
278    // --- Noise estimation: sigma-clipped standard deviation (sample of pixels) ---
279    let step_size = ((height as f64 / 71.0).round() as usize).max(1);
280    // Make step_size odd so it doesn't stride evenly through Bayer rows
281    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                // Exclude outliers (>2× background) and zero pixels
300                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    // --- Star levels: threshold where histogram count drops below empirical limits ---
326    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        // Simple deterministic pseudo-noise using index
377        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        // Background peak should be near 1000
393        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        // Mean should be near 5000, stdev near 100
413        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        // Image with some bright pixels (simulated stars)
420        let mut img = make_image_with_noise(200, 200, 1000.0, 50.0);
421        // Plant 20 "star" pixels well above background
422        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        // star_level should be above noise
431        assert!(bg.star_level > 0.0, "star_level = {}", bg.star_level);
432    }
433
434    /// Gaussian noise from a fixed-seed generator.
435    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    /// A megapixel frame takes the banded (threaded) histogram path. Its result
447    /// must be the same statistics a small frame of the same noise gives, and NaN
448    /// pixels in any band must be ignored rather than counted as zero.
449    #[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        // Everything above the limit is ignored: nothing left, all zero.
472        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}