use crate::centroid::Centroid;
use crate::error::{Error, Result};
use image::GenericImageView;
use numeris::imageproc::{
connected_components_with_label_buffer, gaussian_blur, BorderMode, Component, Connectivity,
};
use numeris::DynMatrix;
mod par {
#[cfg(feature = "parallel")]
use rayon::prelude::*;
#[cfg(feature = "parallel")]
pub fn map_subtract_clamp(a: &[f32], b: &[f32]) -> Vec<f32> {
a.par_iter()
.zip(b.par_iter())
.map(|(&v, &bg)| (v - bg).max(0.0))
.collect()
}
#[cfg(not(feature = "parallel"))]
pub fn map_subtract_clamp(a: &[f32], b: &[f32]) -> Vec<f32> {
a.iter()
.zip(b.iter())
.map(|(&v, &bg)| (v - bg).max(0.0))
.collect()
}
#[cfg(feature = "parallel")]
pub fn map_subtract(a: &[f32], b: &[f32]) -> Vec<f32> {
a.par_iter()
.zip(b.par_iter())
.map(|(&v, &bg)| v - bg)
.collect()
}
#[cfg(not(feature = "parallel"))]
pub fn map_subtract(a: &[f32], b: &[f32]) -> Vec<f32> {
a.iter().zip(b.iter()).map(|(&v, &bg)| v - bg).collect()
}
#[cfg(feature = "parallel")]
pub fn map_indices<T, F>(n: usize, f: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync + Send,
{
(0..n).into_par_iter().map(f).collect()
}
#[cfg(not(feature = "parallel"))]
pub fn map_indices<T, F>(n: usize, f: F) -> Vec<T>
where
F: Fn(usize) -> T,
{
(0..n).map(f).collect()
}
#[cfg(feature = "parallel")]
pub fn for_each_chunk_mut<T, F>(buf: &mut [T], chunk_len: usize, f: F)
where
T: Send,
F: Fn(usize, &mut [T]) + Sync + Send,
{
buf.par_chunks_mut(chunk_len)
.enumerate()
.for_each(|(i, c)| f(i, c));
}
#[cfg(not(feature = "parallel"))]
pub fn for_each_chunk_mut<T, F>(buf: &mut [T], chunk_len: usize, mut f: F)
where
F: FnMut(usize, &mut [T]),
{
for (i, c) in buf.chunks_mut(chunk_len).enumerate() {
f(i, c);
}
}
}
#[derive(Debug, Clone)]
pub struct CentroidExtractionConfig {
pub sigma_threshold: f32,
pub min_pixels: usize,
pub max_pixels: usize,
pub max_centroids: Option<usize>,
pub sigma_clip_iterations: usize,
pub sigma_clip_factor: f32,
pub use_8_connectivity: bool,
pub local_bg_block_size: Option<u32>,
pub max_elongation: Option<f32>,
pub matched_filter_sigma: Option<f32>,
}
impl Default for CentroidExtractionConfig {
fn default() -> Self {
Self {
sigma_threshold: 5.0,
min_pixels: 3,
max_pixels: 10000,
max_centroids: None,
sigma_clip_iterations: 5,
sigma_clip_factor: 3.0,
use_8_connectivity: true,
local_bg_block_size: Some(64),
max_elongation: Some(3.0),
matched_filter_sigma: None,
}
}
}
#[derive(Debug, Clone)]
pub struct CentroidExtractionResult {
pub centroids: Vec<Centroid>,
pub image_width: u32,
pub image_height: u32,
pub background_mean: f32,
pub background_sigma: f32,
pub threshold: f32,
pub num_blobs_raw: usize,
}
pub fn extract_centroids_from_image(
img: &image::DynamicImage,
config: &CentroidExtractionConfig,
) -> Result<CentroidExtractionResult> {
let (width, height) = img.dimensions();
let gray = to_grayscale_f32(img);
extract_from_gray(&gray, width, height, config)
}
pub fn extract_centroids_from_raw(
pixels: &[f32],
width: u32,
height: u32,
config: &CentroidExtractionConfig,
) -> Result<CentroidExtractionResult> {
let expected = (width as usize) * (height as usize);
if pixels.len() != expected {
return Err(Error::InvalidInput(format!(
"Pixel data length ({}) does not match width*height ({}x{}={})",
pixels.len(),
width,
height,
expected,
)));
}
extract_from_gray(pixels, width, height, config)
}
#[derive(Debug, Clone)]
pub struct FastCentroidConfig {
pub sigma_threshold: f32,
pub bg_grid: u32,
pub min_pixels: usize,
pub max_centroids: Option<usize>,
}
impl Default for FastCentroidConfig {
fn default() -> Self {
Self {
sigma_threshold: 5.0,
bg_grid: 64,
min_pixels: 2,
max_centroids: None,
}
}
}
#[derive(Clone, Copy)]
struct Region {
parent: u32,
sum_w: f64, sum_wx: f64, sum_wy: f64, npix: u32,
peak_val: f32,
peak_x: u32,
peak_y: u32,
}
pub fn extract_centroids_fast(
pixels: &[f32],
width: u32,
height: u32,
config: &FastCentroidConfig,
) -> Result<CentroidExtractionResult> {
let w = width as usize;
let h = height as usize;
let expected = w * h;
if pixels.len() != expected {
return Err(Error::InvalidInput(format!(
"Pixel data length ({}) does not match width*height ({}x{}={})",
pixels.len(),
width,
height,
expected,
)));
}
if !(config.sigma_threshold.is_finite() && config.sigma_threshold > 0.0) {
return Err(Error::InvalidInput(format!(
"sigma_threshold must be finite and positive, got {}",
config.sigma_threshold
)));
}
if config.bg_grid == 0 {
return Err(Error::InvalidInput("bg_grid must be >= 1".into()));
}
if w < 2 || h < 2 {
return Err(Error::InvalidInput("image must be at least 2x2".into()));
}
let block = config.bg_grid as usize;
let (bg_grid, nx, ny, sigma) = coarse_background(pixels, w, h, block);
let k = config.sigma_threshold;
let mut regions: Vec<Region> = Vec::new();
let mut prev: Vec<(u32, u32, u32)> = Vec::new();
let mut cur: Vec<(u32, u32, u32)> = Vec::new();
for r in 0..h {
cur.clear();
let row = r * w;
let mut active: Option<(u32, Region)> = None;
for c in 0..w {
let bg = bilinear_grid(&bg_grid, nx, ny, block, c, r);
let p = pixels[row + c];
let lit = p.is_finite() && p > bg + k * sigma;
if lit {
let weight = (p - bg).max(0.0) as f64;
if active.is_none() {
active = Some((
c as u32,
Region {
parent: 0, sum_w: 0.0,
sum_wx: 0.0,
sum_wy: 0.0,
npix: 0,
peak_val: f32::NEG_INFINITY,
peak_x: c as u32,
peak_y: r as u32,
},
));
}
let reg = &mut active.as_mut().unwrap().1;
reg.sum_w += weight;
reg.sum_wx += weight * c as f64;
reg.sum_wy += weight * r as f64;
reg.npix += 1;
if p > reg.peak_val {
reg.peak_val = p;
reg.peak_x = c as u32;
reg.peak_y = r as u32;
}
} else if let Some((start, mut reg)) = active.take() {
let label = regions.len() as u32;
reg.parent = label;
regions.push(reg);
cur.push((start, c as u32 - 1, label));
}
}
if let Some((start, mut reg)) = active.take() {
let label = regions.len() as u32;
reg.parent = label;
regions.push(reg);
cur.push((start, w as u32 - 1, label));
}
let (mut i, mut j) = (0usize, 0usize);
while i < cur.len() && j < prev.len() {
let (cs, ce, cl) = cur[i];
let (ps, pe, pl) = prev[j];
if ce + 1 < ps {
i += 1; } else if pe + 1 < cs {
j += 1; } else {
union(&mut regions, cl, pl);
if ce < pe {
i += 1;
} else {
j += 1;
}
}
}
std::mem::swap(&mut prev, &mut cur);
}
let n_labels = regions.len();
for lab in 0..n_labels {
let root = find(&mut regions, lab as u32) as usize;
if root != lab {
let (sw, swx, swy, np, pv, px, py) = {
let c = ®ions[lab];
(
c.sum_w, c.sum_wx, c.sum_wy, c.npix, c.peak_val, c.peak_x, c.peak_y,
)
};
let rt = &mut regions[root];
rt.sum_w += sw;
rt.sum_wx += swx;
rt.sum_wy += swy;
rt.npix += np;
if pv > rt.peak_val {
rt.peak_val = pv;
rt.peak_x = px;
rt.peak_y = py;
}
}
}
let cx = (width - 1) as f32 / 2.0;
let cy = (height - 1) as f32 / 2.0;
let mut centroids: Vec<Centroid> = Vec::new();
let mut num_blobs_raw = 0usize;
for lab in 0..n_labels {
if find(&mut regions, lab as u32) as usize != lab {
continue; }
num_blobs_raw += 1;
let reg = regions[lab];
if (reg.npix as usize) < config.min_pixels || reg.sum_w <= 0.0 {
continue;
}
let mut fx = reg.sum_wx / reg.sum_w;
let mut fy = reg.sum_wy / reg.sum_w;
let (pc, pr) = (reg.peak_x as usize, reg.peak_y as usize);
if reg.npix >= 5 && pc >= 1 && pr >= 1 && pc + 1 < w && pr + 1 < h {
let bg = bilinear_grid(&bg_grid, nx, ny, block, pc, pr) as f64;
let v = |dy: isize, dx: isize| -> f64 {
let rr = (pr as isize + dy) as usize;
let cc = (pc as isize + dx) as usize;
pixels[rr * w + cc] as f64 - bg
};
if let Some((x_off, y_off)) = quadratic_peak_offset(v) {
let (qx, qy) = (pc as f64 + x_off, pr as f64 + y_off);
if (qx - fx).powi(2) + (qy - fy).powi(2) < 0.25 {
fx = qx;
fy = qy;
}
}
}
centroids.push(Centroid {
x: fx as f32 - cx,
y: fy as f32 - cy,
mass: Some(reg.sum_w as f32),
cov: None,
});
}
centroids.sort_by(|a, b| {
b.mass
.unwrap_or(0.0)
.partial_cmp(&a.mass.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(max) = config.max_centroids {
centroids.truncate(max);
}
let bg_mean = {
let mut g = bg_grid.clone();
let m = g.len() / 2;
let (_, nth, _) = g.select_nth_unstable_by(m, |a, b| a.partial_cmp(b).unwrap());
*nth
};
Ok(CentroidExtractionResult {
centroids,
image_width: width,
image_height: height,
background_mean: bg_mean,
background_sigma: sigma,
threshold: bg_mean + k * sigma,
num_blobs_raw,
})
}
fn find(regions: &mut [Region], mut x: u32) -> u32 {
while regions[x as usize].parent != x {
let parent = regions[x as usize].parent;
regions[x as usize].parent = regions[parent as usize].parent; x = regions[x as usize].parent;
}
x
}
fn union(regions: &mut [Region], a: u32, b: u32) {
let ra = find(regions, a);
let rb = find(regions, b);
if ra != rb {
regions[ra as usize].parent = rb;
}
}
fn coarse_background(
pixels: &[f32],
w: usize,
h: usize,
block: usize,
) -> (Vec<f32>, usize, usize, f32) {
let nx = w.div_ceil(block);
let ny = h.div_ceil(block);
let stride = (block / 8).max(1);
let mut grid = vec![0.0_f32; nx * ny];
let mut sq_sum = 0.0_f64;
let mut sq_n = 0usize;
let mut samples: Vec<f32> = Vec::with_capacity((block / stride + 1).pow(2));
for by in 0..ny {
let y0 = by * block;
let y1 = (y0 + block).min(h);
for bx in 0..nx {
let x0 = bx * block;
let x1 = (x0 + block).min(w);
samples.clear();
let mut y = y0;
while y < y1 {
let row = y * w;
let mut x = x0;
while x < x1 {
let v = pixels[row + x];
if v.is_finite() {
samples.push(v);
}
x += stride;
}
y += stride;
}
let median = if samples.is_empty() {
0.0
} else {
let m = samples.len() / 2;
let (_, nth, _) =
samples.select_nth_unstable_by(m, |a, b| a.partial_cmp(b).unwrap());
*nth
};
grid[by * nx + bx] = median;
for &v in samples.iter() {
if v <= median {
let d = (v - median) as f64;
sq_sum += d * d;
sq_n += 1;
}
}
}
}
let sigma = if sq_n > 0 {
(sq_sum / sq_n as f64).sqrt() as f32
} else {
0.0
};
(grid, nx, ny, sigma)
}
fn bilinear_grid(grid: &[f32], nx: usize, ny: usize, block: usize, x: usize, y: usize) -> f32 {
let half = block as f32 / 2.0;
let fx = (x as f32 - half) / block as f32;
let fy = (y as f32 - half) / block as f32;
let bx0 = (fx.floor().max(0.0) as usize).min(nx - 1);
let by0 = (fy.floor().max(0.0) as usize).min(ny - 1);
let bx1 = (bx0 + 1).min(nx - 1);
let by1 = (by0 + 1).min(ny - 1);
let tx = (fx - bx0 as f32).clamp(0.0, 1.0);
let ty = (fy - by0 as f32).clamp(0.0, 1.0);
let g = |bx: usize, by: usize| grid[by * nx + bx];
let top = g(bx0, by0) * (1.0 - tx) + g(bx1, by0) * tx;
let bot = g(bx0, by1) * (1.0 - tx) + g(bx1, by1) * tx;
top * (1.0 - ty) + bot * ty
}
fn extract_from_gray(
gray_input: &[f32],
width: u32,
height: u32,
config: &CentroidExtractionConfig,
) -> Result<CentroidExtractionResult> {
let w = width as usize;
let h = height as usize;
let gray: Vec<f32>;
let local_bg: Option<Vec<f32>>;
if let Some(block_size) = config.local_bg_block_size {
let bg = estimate_local_background(gray_input, width, height, block_size);
gray = par::map_subtract_clamp(gray_input, &bg);
local_bg = Some(bg);
} else {
gray = gray_input.to_vec();
local_bg = None;
}
let noise_input = if let Some(ref bg) = local_bg {
par::map_subtract(gray_input, bg)
} else {
gray_input.to_vec()
};
let (bg_mean, bg_sigma) = estimate_background(&noise_input, width, height, config);
let threshold = bg_mean + config.sigma_threshold * bg_sigma;
let filtered: Option<Vec<f32>> = match config.matched_filter_sigma {
Some(sigma) if sigma.is_finite() && sigma > 0.0 => {
let mat = DynMatrix::<f32>::from_vec(w, h, gray.clone());
Some(gaussian_blur(&mat, sigma, BorderMode::Replicate).into_vec())
}
_ => None,
};
let thresh_src: &[f32] = filtered.as_deref().unwrap_or(&gray);
let mask = DynMatrix::<u8>::from_fn(h, w, |r, c| {
if thresh_src[r * w + c] > threshold {
1u8
} else {
0u8
}
});
let connectivity = if config.use_8_connectivity {
Connectivity::Eight
} else {
Connectivity::Four
};
let (labels, components) = connected_components_with_label_buffer(&mask, connectivity, 0u8);
let bg_for_centroids = if local_bg.is_some() {
0.0
} else {
bg_mean
};
let raw_centroids = compute_blob_centroids(
&gray,
&labels,
&components,
width,
height,
bg_for_centroids,
config,
);
let num_blobs_raw = raw_centroids.len();
let cx = (width - 1) as f32 / 2.0;
let cy = (height - 1) as f32 / 2.0;
let mut centroids: Vec<Centroid> = raw_centroids
.into_iter()
.map(|rc| Centroid {
x: rc.x_px - cx,
y: rc.y_px - cy,
mass: Some(rc.mass),
cov: Some(rc.cov),
})
.collect();
centroids.sort_by(|a, b| {
b.mass
.unwrap_or(0.0)
.partial_cmp(&a.mass.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(max) = config.max_centroids {
centroids.truncate(max);
}
Ok(CentroidExtractionResult {
centroids,
image_width: width,
image_height: height,
background_mean: bg_mean,
background_sigma: bg_sigma,
threshold,
num_blobs_raw,
})
}
fn estimate_local_background(pixels: &[f32], width: u32, height: u32, block_size: u32) -> Vec<f32> {
let w = width as usize;
let h = height as usize;
let bs = block_size as usize;
let nx = w.div_ceil(bs);
let ny = h.div_ceil(bs);
let block_medians: Vec<f32> = par::map_indices(nx * ny, |bi| {
let bx = bi % nx;
let by = bi / nx;
let x0 = bx * bs;
let y0 = by * bs;
let x1 = (x0 + bs).min(w);
let y1 = (y0 + bs).min(h);
let mut vals: Vec<f32> = Vec::with_capacity(bs * bs);
for y in y0..y1 {
for x in x0..x1 {
let v = pixels[y * w + x];
if v > 0.0 && v.is_finite() {
vals.push(v);
}
}
}
if vals.is_empty() {
0.0
} else {
vals.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
vals[vals.len() / 2]
}
});
let mut background = vec![0.0f32; w * h];
let half_bs = bs as f32 / 2.0;
par::for_each_chunk_mut(&mut background, w, |y, row| {
let by_f = (y as f32 - half_bs) / bs as f32;
let by0 = (by_f.floor() as isize).max(0).min(ny as isize - 1) as usize;
let by1 = (by0 + 1).min(ny - 1);
let fy = (by_f - by0 as f32).clamp(0.0, 1.0);
for (x, px) in row.iter_mut().enumerate() {
let bx_f = (x as f32 - half_bs) / bs as f32;
let bx0 = (bx_f.floor() as isize).max(0).min(nx as isize - 1) as usize;
let bx1 = (bx0 + 1).min(nx - 1);
let fx = (bx_f - bx0 as f32).clamp(0.0, 1.0);
let m00 = block_medians[by0 * nx + bx0];
let m10 = block_medians[by0 * nx + bx1];
let m01 = block_medians[by1 * nx + bx0];
let m11 = block_medians[by1 * nx + bx1];
*px = m00 * (1.0 - fx) * (1.0 - fy)
+ m10 * fx * (1.0 - fy)
+ m01 * (1.0 - fx) * fy
+ m11 * fx * fy;
}
});
background
}
fn to_grayscale_f32(img: &image::DynamicImage) -> Vec<f32> {
use image::DynamicImage;
match img {
DynamicImage::ImageLuma16(g) => g.as_raw().iter().map(|&v| v as f32).collect(),
DynamicImage::ImageLumaA16(g) => g.pixels().map(|p| p.0[0] as f32).collect(),
DynamicImage::ImageRgb16(rgb) => rgb
.pixels()
.map(|p| {
let [r, g, b] = p.0;
0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32
})
.collect(),
DynamicImage::ImageRgba16(rgba) => rgba
.pixels()
.map(|p| {
let [r, g, b, _] = p.0;
0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32
})
.collect(),
DynamicImage::ImageRgb32F(rgb) => rgb
.pixels()
.map(|p| {
let [r, g, b] = p.0;
0.2126 * r + 0.7152 * g + 0.0722 * b
})
.collect(),
DynamicImage::ImageRgba32F(rgba) => rgba
.pixels()
.map(|p| {
let [r, g, b, _] = p.0;
0.2126 * r + 0.7152 * g + 0.0722 * b
})
.collect(),
_ => {
let gray = img.to_luma8();
gray.as_raw().iter().map(|&v| v as f32).collect()
}
}
}
fn estimate_background(
gray: &[f32],
_width: u32,
_height: u32,
config: &CentroidExtractionConfig,
) -> (f32, f32) {
let mut values: Vec<f32> = gray.iter().copied().filter(|v| v.is_finite()).collect();
if values.is_empty() {
return (0.0, 0.0);
}
let n = values.len();
let cmp = |a: &f32, b: &f32| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal);
let median = {
let (lower, nth, _) = values.select_nth_unstable_by(n / 2, cmp);
if n.is_multiple_of(2) {
let prev = lower.iter().copied().fold(f32::NEG_INFINITY, f32::max);
(prev + *nth) / 2.0
} else {
*nth
}
};
let mut low_half: Vec<f32> = values.iter().copied().filter(|&v| v <= median).collect();
let mut sigma = 0.0_f32;
for _ in 0..config.sigma_clip_iterations {
if low_half.is_empty() {
break;
}
let sum: f64 = low_half.iter().map(|&v| v as f64).sum();
let mean_low = (sum / low_half.len() as f64) as f32;
let var_sum: f64 = low_half
.iter()
.map(|&v| ((v - mean_low) as f64).powi(2))
.sum();
sigma = (var_sum / low_half.len() as f64).sqrt() as f32;
if sigma < 1e-10 {
break;
}
let lo = mean_low - config.sigma_clip_factor * sigma;
let hi = mean_low + config.sigma_clip_factor * sigma;
let before = low_half.len();
low_half.retain(|&v| v >= lo && v <= hi);
if low_half.len() == before {
break; }
}
(median, sigma)
}
fn quadratic_peak_offset(v: impl Fn(isize, isize) -> f64) -> Option<(f64, f64)> {
let b = (v(0, 1) - v(0, -1)) / 2.0;
let c_coeff = (v(1, 0) - v(-1, 0)) / 2.0;
let d = (v(0, 1) + v(0, -1) - 2.0 * v(0, 0)) / 2.0;
let f = (v(1, 0) + v(-1, 0) - 2.0 * v(0, 0)) / 2.0;
let e = (v(1, 1) - v(1, -1) - v(-1, 1) + v(-1, -1)) / 4.0;
let denom = 4.0 * d * f - e * e;
if denom.abs() <= 1e-10 {
return None;
}
let x_off = (e * c_coeff - 2.0 * f * b) / denom;
let y_off = (e * b - 2.0 * d * c_coeff) / denom;
if x_off.abs() <= 0.5 && y_off.abs() <= 0.5 {
Some((x_off, y_off))
} else {
None
}
}
struct RawCentroid {
x_px: f32,
y_px: f32,
mass: f32,
cov: crate::Matrix2,
}
fn compute_blob_centroids(
gray: &[f32],
labels: &[u32],
components: &[Component],
width: u32,
height: u32,
bg_level: f32,
config: &CentroidExtractionConfig,
) -> Vec<RawCentroid> {
let w = width as usize;
let h = height as usize;
let bg_level_f64 = bg_level as f64;
let mut annulus_vals: Vec<f32> = Vec::new();
components
.iter()
.enumerate()
.filter_map(|(idx, comp)| {
let blob_label = (idx + 1) as u32;
let pixel_count = comp.area as usize;
if pixel_count < config.min_pixels || pixel_count > config.max_pixels {
return None;
}
let min_row = comp.bbox_min.0 as usize;
let max_row = comp.bbox_max.0 as usize;
let min_col = comp.bbox_min.1 as usize;
let max_col = comp.bbox_max.1 as usize;
let ref_col = min_col;
let ref_row = min_row;
let mut sum_x = 0.0_f64;
let mut sum_y = 0.0_f64;
let mut sum_xx = 0.0_f64;
let mut sum_yy = 0.0_f64;
let mut sum_xy = 0.0_f64;
let mut sum_i = 0.0_f64;
let mut peak_val = f32::NEG_INFINITY;
let mut peak_col: usize = ref_col;
let mut peak_row: usize = ref_row;
for r in min_row..=max_row {
let row_off = r * w;
for c in min_col..=max_col {
let i = row_off + c;
if labels[i] != blob_label {
continue;
}
let raw = gray[i];
if raw > peak_val {
peak_val = raw;
peak_col = c;
peak_row = r;
}
let intensity = (raw as f64 - bg_level_f64).max(0.0);
let dx = c as f64 - ref_col as f64;
let dy = r as f64 - ref_row as f64;
sum_x += dx * intensity;
sum_y += dy * intensity;
sum_xx += dx * dx * intensity;
sum_yy += dy * dy * intensity;
sum_xy += dx * dy * intensity;
sum_i += intensity;
}
}
if sum_i <= 0.0 {
return None;
}
if let Some(max_elong) = config.max_elongation {
let dx_bar = sum_x / sum_i;
let dy_bar = sum_y / sum_i;
let cxx = sum_xx / sum_i - dx_bar * dx_bar;
let cyy = sum_yy / sum_i - dy_bar * dy_bar;
let cxy = sum_xy / sum_i - dx_bar * dy_bar;
let trace = cxx + cyy;
let det = cxx * cyy - cxy * cxy;
let disc = (trace * trace - 4.0 * det).max(0.0).sqrt();
let lambda_max = (trace + disc) / 2.0;
let lambda_min = (trace - disc).max(1e-12) / 2.0;
let elongation = (lambda_max / lambda_min).sqrt() as f32;
if elongation > max_elong {
return None;
}
}
const ANNULUS_MARGIN: usize = 5;
let r0 = min_row.saturating_sub(ANNULUS_MARGIN);
let r1 = (max_row + ANNULUS_MARGIN + 1).min(h);
let c0 = min_col.saturating_sub(ANNULUS_MARGIN);
let c1 = (max_col + ANNULUS_MARGIN + 1).min(w);
annulus_vals.clear();
for r in r0..r1 {
let row_off = r * w;
for c in c0..c1 {
let i = row_off + c;
if labels[i] == 0 {
annulus_vals.push(gray[i]);
}
}
}
let local_bg = if annulus_vals.is_empty() {
0.0_f64
} else {
let m = annulus_vals.len();
let mid = m / 2;
let (lower, nth, _) =
annulus_vals.select_nth_unstable_by(mid, |a, b| a.partial_cmp(b).unwrap());
if m.is_multiple_of(2) {
let prev = lower.iter().copied().fold(f32::NEG_INFINITY, f32::max);
(prev + *nth) as f64 / 2.0
} else {
*nth as f64
}
};
sum_x = 0.0;
sum_y = 0.0;
sum_xx = 0.0;
sum_yy = 0.0;
sum_xy = 0.0;
sum_i = 0.0;
for r in min_row..=max_row {
let row_off = r * w;
for c in min_col..=max_col {
let i = row_off + c;
if labels[i] != blob_label {
continue;
}
let intensity = (gray[i] as f64 - local_bg).max(0.0);
let dx = c as f64 - ref_col as f64;
let dy = r as f64 - ref_row as f64;
sum_x += dx * intensity;
sum_y += dy * intensity;
sum_xx += dx * dx * intensity;
sum_yy += dy * dy * intensity;
sum_xy += dx * dy * intensity;
sum_i += intensity;
}
}
if sum_i <= 0.0 {
return None;
}
let dx_bar = sum_x / sum_i;
let dy_bar = sum_y / sum_i;
let xbar = ref_col as f64 + dx_bar;
let ybar = ref_row as f64 + dy_bar;
let cxx = sum_xx / sum_i - dx_bar * dx_bar;
let cyy = sum_yy / sum_i - dy_bar * dy_bar;
let cxy = sum_xy / sum_i - dx_bar * dy_bar;
let mut final_x = xbar;
let mut final_y = ybar;
let pc = peak_col;
let pr = peak_row;
if pixel_count >= 5 && pc >= 1 && pr >= 1 && pc + 1 < w && pr + 1 < h {
let effective_bg = local_bg;
let v = |dy: isize, dx: isize| -> f64 {
let r = (pr as isize + dy) as usize;
let c = (pc as isize + dx) as usize;
gray[r * w + c] as f64 - effective_bg
};
if let Some((x_off, y_off)) = quadratic_peak_offset(v) {
let qx = pc as f64 + x_off;
let qy = pr as f64 + y_off;
let dist_sq = (qx - xbar) * (qx - xbar) + (qy - ybar) * (qy - ybar);
if dist_sq < 0.25 {
final_x = qx;
final_y = qy;
}
}
}
Some(RawCentroid {
x_px: final_x as f32,
y_px: final_y as f32,
mass: sum_i as f32,
cov: crate::Matrix2::new([[cxx as f32, cxy as f32], [cxy as f32, cyy as f32]]),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_background_estimation() {
let pixels = vec![100.0_f32; 100 * 100];
let config = CentroidExtractionConfig::default();
let (mean, sigma) = estimate_background(&pixels, 100, 100, &config);
assert!((mean - 100.0).abs() < 1.0);
assert!(sigma < 1.0);
}
#[test]
fn test_extract_from_raw_single_star() {
let width = 64u32;
let height = 64u32;
let mut pixels = vec![10.0_f32; (width * height) as usize];
let star_x = 32.0_f32;
let star_y = 32.0_f32;
let sigma_px = 2.0_f32;
for row in 0..height {
for col in 0..width {
let dx = col as f32 - star_x;
let dy = row as f32 - star_y;
let r2 = dx * dx + dy * dy;
pixels[(row * width + col) as usize] +=
1000.0 * (-r2 / (2.0 * sigma_px * sigma_px)).exp();
}
}
let config = CentroidExtractionConfig {
sigma_threshold: 3.0,
min_pixels: 2,
..Default::default()
};
let result = extract_centroids_from_raw(&pixels, width, height, &config).unwrap();
assert_eq!(result.centroids.len(), 1);
let c = &result.centroids[0];
assert!(c.x.abs() < 1.0, "Expected x near 0, got {}", c.x);
assert!(c.y.abs() < 1.0, "Expected y near 0, got {}", c.y);
assert!(c.mass.unwrap() > 0.0);
}
fn render_stars(
width: u32,
height: u32,
bg: f32,
gradient: f32,
noise: f32,
sigma_px: f32,
stars: &[(f32, f32, f32)],
) -> Vec<f32> {
let (w, h) = (width as usize, height as usize);
let mut pixels = vec![0.0_f32; w * h];
for row in 0..h {
for col in 0..w {
let dither = (((row * w + col) as u32).wrapping_mul(2_654_435_761) >> 8) as f32
/ 16_777_216.0
- 0.5;
pixels[row * w + col] = bg + gradient * (col as f32 / w as f32) + noise * dither;
}
}
for &(sx, sy, brightness) in stars {
for row in 0..h {
for col in 0..w {
let dx = col as f32 - sx;
let dy = row as f32 - sy;
let r2 = dx * dx + dy * dy;
pixels[row * w + col] += brightness * (-r2 / (2.0 * sigma_px * sigma_px)).exp();
}
}
}
pixels
}
#[test]
fn test_fast_extract_recovers_stars_over_gradient() {
let (width, height) = (128u32, 128u32);
let sigma_px = 1.6_f32;
let stars = [
(30.3, 30.0, 900.0),
(90.0, 50.7, 1300.0),
(60.5, 100.2, 600.0),
];
let pixels = render_stars(width, height, 50.0, 400.0, 8.0, sigma_px, &stars);
let config = FastCentroidConfig {
sigma_threshold: 5.0,
bg_grid: 32,
..Default::default()
};
let result = extract_centroids_fast(&pixels, width, height, &config).unwrap();
assert_eq!(
result.centroids.len(),
3,
"expected 3 stars, got {}",
result.centroids.len()
);
assert!(result.centroids[0].mass.unwrap() >= result.centroids[1].mass.unwrap());
let cx = (width - 1) as f32 / 2.0;
let cy = (height - 1) as f32 / 2.0;
for &(sx, sy, _) in &stars {
let (tx, ty) = (sx - cx, sy - cy);
let best = result
.centroids
.iter()
.map(|c| ((c.x - tx).powi(2) + (c.y - ty).powi(2)).sqrt())
.fold(f32::INFINITY, f32::min);
assert!(
best < 0.6,
"star ({sx}, {sy}) nearest detection {best:.3} px away"
);
}
}
#[test]
fn test_fast_extract_merges_touching_pixels_and_caps() {
let (width, height) = (128u32, 128u32);
let stars = [
(64.0, 64.0, 1000.0),
(65.0, 64.0, 950.0),
(20.0, 20.0, 800.0),
];
let pixels = render_stars(width, height, 30.0, 0.0, 6.0, 1.5, &stars);
let config = FastCentroidConfig {
sigma_threshold: 5.0,
max_centroids: Some(5),
..Default::default()
};
let result = extract_centroids_fast(&pixels, width, height, &config).unwrap();
assert_eq!(
result.centroids.len(),
2,
"blended pair should merge to 1 + 1 separate = 2, got {}",
result.centroids.len()
);
}
#[test]
fn test_fast_extract_rejects_bad_params() {
let pixels = vec![0.0_f32; 64 * 64];
let bad_sigma = FastCentroidConfig {
sigma_threshold: 0.0,
..Default::default()
};
assert!(extract_centroids_fast(&pixels, 64, 64, &bad_sigma).is_err());
let bad_grid = FastCentroidConfig {
bg_grid: 0,
..Default::default()
};
assert!(extract_centroids_fast(&pixels, 64, 64, &bad_grid).is_err());
assert!(extract_centroids_fast(&pixels, 64, 63, &FastCentroidConfig::default()).is_err());
}
#[test]
fn test_extract_from_raw_multiple_stars() {
let width = 128u32;
let height = 128u32;
let mut pixels = vec![10.0_f32; (width * height) as usize];
let stars = [
(30.0, 30.0, 800.0),
(90.0, 50.0, 1200.0),
(60.0, 100.0, 500.0),
];
let sigma_px = 2.0_f32;
for &(sx, sy, brightness) in &stars {
for row in 0..height {
for col in 0..width {
let dx = col as f32 - sx;
let dy = row as f32 - sy;
let r2 = dx * dx + dy * dy;
pixels[(row * width + col) as usize] +=
brightness * (-r2 / (2.0 * sigma_px * sigma_px)).exp();
}
}
}
let config = CentroidExtractionConfig {
sigma_threshold: 3.0,
min_pixels: 2,
..Default::default()
};
let result = extract_centroids_from_raw(&pixels, width, height, &config).unwrap();
assert_eq!(
result.centroids.len(),
3,
"Expected 3 stars, got {}",
result.centroids.len()
);
assert!(result.centroids[0].mass.unwrap() >= result.centroids[1].mass.unwrap());
assert!(result.centroids[1].mass.unwrap() >= result.centroids[2].mass.unwrap());
}
#[test]
fn test_max_centroids_limit() {
let width = 128u32;
let height = 128u32;
let mut pixels = vec![10.0_f32; (width * height) as usize];
let stars = [
(30.0, 30.0, 800.0),
(90.0, 50.0, 1200.0),
(60.0, 100.0, 500.0),
];
let sigma_px = 2.0_f32;
for &(sx, sy, brightness) in &stars {
for row in 0..height {
for col in 0..width {
let dx = col as f32 - sx;
let dy = row as f32 - sy;
let r2 = dx * dx + dy * dy;
pixels[(row * width + col) as usize] +=
brightness * (-r2 / (2.0 * sigma_px * sigma_px)).exp();
}
}
}
let config = CentroidExtractionConfig {
sigma_threshold: 3.0,
min_pixels: 2,
max_centroids: Some(2),
..Default::default()
};
let result = extract_centroids_from_raw(&pixels, width, height, &config).unwrap();
assert_eq!(result.centroids.len(), 2);
}
#[test]
fn test_quadratic_refinement() {
let width = 64u32;
let height = 64u32;
let bg = 100.0_f32;
let true_x = 32.3_f32;
let true_y = 32.7_f32;
let sigma_px = 2.0_f32;
let peak_brightness = 2000.0_f32;
let mut pixels = vec![bg; (width * height) as usize];
for row in 0..height {
for col in 0..width {
let dx = col as f32 - true_x;
let dy = row as f32 - true_y;
let r2 = dx * dx + dy * dy;
pixels[(row * width + col) as usize] +=
peak_brightness * (-r2 / (2.0 * sigma_px * sigma_px)).exp();
}
}
let config = CentroidExtractionConfig {
sigma_threshold: 3.0,
min_pixels: 3,
..Default::default()
};
let result = extract_centroids_from_raw(&pixels, width, height, &config).unwrap();
assert_eq!(
result.centroids.len(),
1,
"Expected 1 star, got {}",
result.centroids.len()
);
let c = &result.centroids[0];
let cx = (width - 1) as f32 / 2.0;
let cy = (height - 1) as f32 / 2.0;
let abs_x = c.x + cx;
let abs_y = c.y + cy;
let err_x = (abs_x - true_x).abs();
let err_y = (abs_y - true_y).abs();
assert!(
err_x < 0.15,
"X error too large: centroid={abs_x:.4}, true={true_x}, err={err_x:.4}"
);
assert!(
err_y < 0.15,
"Y error too large: centroid={abs_y:.4}, true={true_y}, err={err_y:.4}"
);
}
#[test]
fn test_quadratic_refinement_with_gradient_background() {
let width = 128u32;
let height = 128u32;
let true_x = 64.4_f32;
let true_y = 64.6_f32;
let sigma_px = 2.0_f32;
let peak_brightness = 2000.0_f32;
let mut pixels = vec![0.0_f32; (width * height) as usize];
for row in 0..height {
for col in 0..width {
let bg = 50.0 + 100.0 * (col as f32 / width as f32);
pixels[(row * width + col) as usize] = bg;
}
}
for row in 0..height {
for col in 0..width {
let dx = col as f32 - true_x;
let dy = row as f32 - true_y;
let r2 = dx * dx + dy * dy;
pixels[(row * width + col) as usize] +=
peak_brightness * (-r2 / (2.0 * sigma_px * sigma_px)).exp();
}
}
let config = CentroidExtractionConfig {
sigma_threshold: 5.0,
min_pixels: 3,
..Default::default()
};
let result = extract_centroids_from_raw(&pixels, width, height, &config).unwrap();
assert!(
!result.centroids.is_empty(),
"Should detect at least one star on gradient background"
);
let cx = (width - 1) as f32 / 2.0;
let cy = (height - 1) as f32 / 2.0;
let best = result
.centroids
.iter()
.min_by(|a, b| {
let da = (a.x + cx - true_x).powi(2) + (a.y + cy - true_y).powi(2);
let db = (b.x + cx - true_x).powi(2) + (b.y + cy - true_y).powi(2);
da.partial_cmp(&db).unwrap()
})
.unwrap();
let abs_x = best.x + cx;
let abs_y = best.y + cy;
let err_x = (abs_x - true_x).abs();
let err_y = (abs_y - true_y).abs();
assert!(
err_x < 0.3,
"X error too large on gradient bg: centroid={abs_x:.4}, true={true_x}, err={err_x:.4}"
);
assert!(
err_y < 0.3,
"Y error too large on gradient bg: centroid={abs_y:.4}, true={true_y}, err={err_y:.4}"
);
}
}