Skip to main content

arcsec_core/detection/
analyse.rs

1//! Image analysis without solving: star count, median HFD and a per-star table.
2//!
3//! This is ASTAP's `analyse_image`, behind its `-analyse`, `-extract` and `-extract2`
4//! options. It differs from the solver's detection ([`crate::detection::stars`]) in
5//! ways that matter to anyone comparing the two:
6//!
7//! - it works on the full-resolution image, never a binned one;
8//! - the minimum SNR is the caller's (`snr_min`), not the solver's fixed 10, and there
9//!   is no minimum HFD beyond the 0.8 px hot-pixel floor;
10//! - each detection pass starts afresh. The solver accumulates stars over its passes,
11//!   whereas here a pass that finds too few stars is discarded and the next, lower,
12//!   threshold scans the whole image again;
13//! - the last pass uses a single threshold of `max(snr_min, 7)` × noise over the
14//!   whole frame, not the solver's grid of local backgrounds;
15//! - nothing is trimmed: every star the final pass finds is reported.
16//!
17//! The per-star measurement is shared with the solver ([`measure_star_with_flux`]).
18
19use crate::detection::background::{Background, get_background};
20use crate::detection::stars::{measure_star_with_flux, median_f64};
21use crate::types::ImageBuffer;
22
23/// A star found by [`analyse_image`].
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct MeasuredStar {
26    /// Sub-pixel column of the centroid (0-based).
27    pub x: f64,
28    /// Sub-pixel row of the centroid (0-based).
29    pub y: f64,
30    /// Half-flux diameter, pixels.
31    pub hfd: f64,
32    /// Signal-to-noise ratio of the aperture flux.
33    pub snr: f64,
34    /// Background-subtracted flux in the measuring aperture, in pixel units (ADU).
35    pub flux: f64,
36}
37
38/// The result of [`analyse_image`].
39#[derive(Debug, Clone)]
40pub struct Analysis {
41    /// Every star the final detection pass found, in scan order (row by row).
42    pub stars: Vec<MeasuredStar>,
43    /// The background and noise the detection thresholds were derived from.
44    pub background: Background,
45}
46
47impl Analysis {
48    /// Median HFD of the stars, in pixels, or `None` if there are none.
49    #[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/// Find and measure the stars of `img` without solving it.
60///
61/// Detection runs in up to four passes at falling thresholds, exactly as ASTAP's
62/// `analyse_image` does:
63///
64/// 1. the bright-star level from the histogram, if it is above 30 × noise;
65/// 2. the fainter histogram level, on the same condition;
66/// 3. 30 × noise, skipped when `snr_min` is 30 or more;
67/// 4. `max(snr_min, 7)` × noise.
68///
69/// It stops at the first pass that finds `max_stars` stars or more (or after the
70/// last), and returns that pass's stars. A star counts if its SNR exceeds `snr_min`
71/// and its HFD lies in (0.8, 30] pixels.
72///
73/// Single-threaded; images smaller than 3×3 yield no stars.
74#[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
119/// One detection pass over the whole image (less a one-pixel border) at
120/// `level` above the background.
121fn 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    // Pixels already claimed by a star found in this pass.
126    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            // A hot pixel stands alone: a star lights at least two of the four
136            // pixels around it.
137            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; // the same star, reached again from another of its pixels
160            }
161
162            // Claim the star's disc so its other pixels do not seed it again.
163            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    /// Gaussian star added into `img`.
194    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            // A Gaussian's HFD is about 2.35 sigma.
243            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        // Scan order: row by row.
247        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        // Peak 12 sigma: above the 7 sigma last-pass threshold, but an aperture SNR
257        // of about 25.
258        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        // Too small to scan at all.
272        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}