use crate::detection::background::{Background, get_background};
use crate::detection::stars::{measure_star_with_flux, median_f64};
use crate::types::ImageBuffer;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MeasuredStar {
pub x: f64,
pub y: f64,
pub hfd: f64,
pub snr: f64,
pub flux: f64,
}
#[derive(Debug, Clone)]
pub struct Analysis {
pub stars: Vec<MeasuredStar>,
pub background: Background,
}
impl Analysis {
#[must_use]
pub fn hfd_median(&self) -> Option<f64> {
if self.stars.is_empty() {
return None;
}
let mut hfds: Vec<f64> = self.stars.iter().map(|s| s.hfd).collect();
Some(median_f64(&mut hfds))
}
}
#[must_use]
pub fn analyse_image(img: &ImageBuffer, snr_min: f64, max_stars: usize) -> Analysis {
let background = get_background(img, max_stars);
let (w, h) = (img.width, img.height);
if w < 3 || h < 3 || img.data.len() < w * h {
return Analysis {
stars: Vec::new(),
background,
};
}
let noise = background.noise;
let mut retries = 4u8;
let stars = loop {
let mut level = background.star_level;
if retries == 4 && background.star_level <= 30.0 * noise {
retries = 3;
}
if retries == 3 {
if background.star_level2 > 30.0 * noise {
level = background.star_level2;
} else {
retries = 2;
}
}
if retries == 2 {
level = 30.0 * noise;
if snr_min >= 30.0 {
retries = 1;
}
}
if retries == 1 {
level = snr_min.max(7.0) * noise;
}
let found = scan(img, &background, level, snr_min);
retries -= 1;
if found.len() >= max_stars || retries == 0 {
break found;
}
};
Analysis { stars, background }
}
fn scan(img: &ImageBuffer, bg: &Background, level: f64, snr_min: f64) -> Vec<MeasuredStar> {
let (w, h) = (img.width, img.height);
let detect_abs = bg.mean + level;
let hot_abs = bg.mean + 4.0 * bg.noise;
let mut taken = vec![false; w * h];
let mut out = Vec::new();
for fy in 1..h - 1 {
let row = fy * w;
for fx in 1..w - 1 {
if (img.data[row + fx] as f64) <= detect_abs || taken[row + fx] {
continue;
}
let lit = [row + fx - 1, row + fx + 1, row - w + fx, row + w + fx]
.iter()
.filter(|&&i| img.data[i] as f64 > hot_abs)
.count();
if lit < 2 {
continue;
}
let Some((star, flux)) = measure_star_with_flux(img, fx as i32, fy as i32) else {
continue;
};
if !(star.hfd <= 30.0 && star.snr > snr_min && star.hfd > 0.8) {
continue;
}
let xci = star.x.round() as i64;
let yci = star.y.round() as i64;
if xci >= 0
&& yci >= 0
&& (xci as usize) < w
&& (yci as usize) < h
&& taken[yci as usize * w + xci as usize]
{
continue; }
let radius = (3.0 * star.hfd).round() as i64;
for n in -radius..=radius {
for m in -radius..=radius {
let (xi, yi) = (xci + m, yci + n);
if xi >= 0
&& yi >= 0
&& (xi as usize) < w
&& (yi as usize) < h
&& m * m + n * n <= radius * radius
{
taken[yi as usize * w + xi as usize] = true;
}
}
}
out.push(MeasuredStar {
x: star.x,
y: star.y,
hfd: star.hfd,
snr: star.snr,
flux,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn add_star(img: &mut ImageBuffer, cx: f64, cy: f64, sigma: f64, peak: f64) {
let rs = (5.0 * sigma).ceil() as i64;
for dy in -rs..=rs {
for dx in -rs..=rs {
let (x, y) = (cx.round() as i64 + dx, cy.round() as i64 + dy);
if x < 0 || y < 0 || x as usize >= img.width || y as usize >= img.height {
continue;
}
let r2 = (x as f64 - cx).powi(2) + (y as f64 - cy).powi(2);
img.data[y as usize * img.width + x as usize] +=
(peak * (-r2 / (2.0 * sigma * sigma)).exp()) as f32;
}
}
}
fn noisy(w: usize, h: usize, bg: f64, sigma: f64) -> ImageBuffer {
let mut rng = crate::test_support::Rng::new(3);
ImageBuffer {
data: (0..w * h)
.map(|_| (bg + sigma * rng.gauss()) as f32)
.collect(),
width: w,
height: h,
}
}
#[test]
fn finds_every_star_with_its_hfd_and_position() {
let mut img = noisy(400, 300, 1000.0, 10.0);
let planted = [
(50.3, 60.7),
(200.0, 150.0),
(330.6, 40.2),
(120.0, 250.5),
(300.0, 220.0),
];
for &(x, y) in &planted {
add_star(&mut img, x, y, 1.5, 5000.0);
}
let a = analyse_image(&img, 30.0, 500);
assert_eq!(a.stars.len(), planted.len(), "{:?}", a.stars);
for s in &a.stars {
assert!(
planted
.iter()
.any(|&(x, y)| (s.x - x).abs() < 0.2 && (s.y - y).abs() < 0.2),
"{s:?} is not a planted star"
);
assert!((s.hfd - 2.35 * 1.5).abs() < 0.6, "hfd {}", s.hfd);
assert!(s.snr > 30.0 && s.flux > 0.0);
}
assert!(a.stars.windows(2).all(|p| p[0].y.round() <= p[1].y.round()));
let median = a.hfd_median().unwrap();
assert!((median - 2.35 * 1.5).abs() < 0.6, "median {median}");
}
#[test]
fn snr_min_sets_the_faintest_star_reported() {
let mut img = noisy(300, 300, 1000.0, 10.0);
add_star(&mut img, 80.0, 80.0, 1.5, 5000.0);
add_star(&mut img, 200.0, 200.0, 2.0, 120.0);
let strict = analyse_image(&img, 30.0, 500);
assert_eq!(strict.stars.len(), 1);
let loose = analyse_image(&img, 5.0, 500);
assert_eq!(loose.stars.len(), 2, "{:?}", loose.stars);
}
#[test]
fn a_blank_frame_has_no_stars_and_no_median() {
let img = noisy(200, 200, 1000.0, 10.0);
let a = analyse_image(&img, 30.0, 500);
assert!(a.stars.is_empty());
assert_eq!(a.hfd_median(), None);
assert!(
analyse_image(&ImageBuffer::new(2, 2), 30.0, 500)
.stars
.is_empty()
);
}
#[test]
fn a_hot_pixel_is_not_a_star() {
let mut img = noisy(100, 100, 1000.0, 10.0);
img.data[50 * 100 + 50] = 60000.0;
assert!(analyse_image(&img, 10.0, 500).stars.is_empty());
}
}