use std::borrow::Cow;
use numeris::imageproc::{gaussian_blur, BorderMode};
use numeris::DynMatrix;
use super::{
accepted_peak_refine, elongation_from_cov, median_f32, par, peak_sharpness, runs,
sort_and_truncate_by_mass, BackgroundGrid, CentroidExtractionConfig, CentroidExtractionResult,
DeblendMode,
};
use crate::centroid::Centroid;
use crate::error::{Error, Result};
pub(super) 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;
if w < 2 || h < 2 {
return Err(Error::InvalidInput(format!(
"image must be at least 2x2, got {width}x{height}"
)));
}
if config.local_bg_block_size == Some(0) {
return Err(Error::InvalidInput(
"local_bg_block_size must be >= 1 (or None)".into(),
));
}
if !config.sigma_threshold.is_finite() {
return Err(Error::InvalidInput(format!(
"sigma_threshold must be finite, got {}",
config.sigma_threshold
)));
}
let filter_sigma = config
.matched_filter_sigma
.filter(|s| s.is_finite() && *s > 0.0);
let gray: Cow<[f32]>;
let bg_mean: f32;
let bg_sigma: f32;
let mut filter_input: Option<DynMatrix<f32>> = None;
if let Some(block_size) = config.local_bg_block_size {
let bs = block_size as usize;
let (bg, _) = BackgroundGrid::build(gray_input, w, h, bs, (bs / 16).max(1));
let residuals = subsample_residuals(gray_input, w, h, &bg);
(bg_mean, bg_sigma) = estimate_background(&residuals, width, height, config);
let mut clamped = vec![0.0_f32; w * h];
if filter_sigma.is_some() {
let mut unclamped = vec![0.0_f32; w * h];
par::for_each_chunk_pair_mut(&mut clamped, &mut unclamped, w, |y, cr, ur| {
let rp = bg.row_params(y);
let row = y * w;
for x in 0..w {
let r = gray_input[row + x] - bg.value_at(x, rp);
cr[x] = r.max(0.0);
ur[x] = r;
}
});
filter_input = Some(DynMatrix::from_vec(w, h, unclamped));
} else {
par::for_each_chunk_mut(&mut clamped, w, |y, cr| {
let rp = bg.row_params(y);
let row = y * w;
for (x, out) in cr.iter_mut().enumerate() {
*out = (gray_input[row + x] - bg.value_at(x, rp)).max(0.0);
}
});
}
gray = Cow::Owned(clamped);
} else {
(bg_mean, bg_sigma) = estimate_background(gray_input, width, height, config);
gray = Cow::Borrowed(gray_input);
if filter_sigma.is_some() {
filter_input = Some(DynMatrix::from_vec(w, h, gray_input.to_vec()));
}
}
let gray: &[f32] = &gray;
let (thresh_src, mask_threshold): (Cow<[f32]>, f32) = match (filter_sigma, filter_input) {
(Some(sigma), Some(mat)) => {
let filtered = gaussian_blur(&mat, sigma, BorderMode::Replicate).into_vec();
let suppression = gaussian_noise_suppression(sigma);
(
Cow::Owned(filtered),
bg_mean + config.sigma_threshold * bg_sigma * suppression,
)
}
_ => (
Cow::Borrowed(gray),
bg_mean + config.sigma_threshold * bg_sigma,
),
};
let thresh_src: &[f32] = &thresh_src;
let regions = runs::sweep_runs(w, h, |r, c| thresh_src[r * w + c] > mask_threshold);
let raw_centroids = compute_blob_centroids(
gray,
gray_input,
thresh_src,
mask_threshold,
®ions,
width,
height,
config,
);
let num_blobs_raw = regions.n_regions;
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();
sort_and_truncate_by_mass(&mut centroids, config.max_centroids);
Ok(CentroidExtractionResult {
centroids,
image_width: width,
image_height: height,
background_mean: bg_mean,
background_sigma: bg_sigma,
threshold: mask_threshold,
num_blobs_raw,
})
}
fn subsample_residuals(pixels: &[f32], w: usize, h: usize, bg: &BackgroundGrid) -> Vec<f32> {
let stride = bg.stride();
let mut out: Vec<f32> = Vec::with_capacity((w / stride + 1) * (h / stride + 1));
let mut y = 0usize;
let mut phase = 0usize;
while y < h {
let rp = bg.row_params(y);
let row = y * w;
let mut x = phase;
while x < w {
let v = pixels[row + x];
if v.is_finite() {
out.push(v - bg.value_at(x, rp));
}
x += stride;
}
phase = (phase + 1) % stride;
y += stride;
}
out
}
fn gaussian_noise_suppression(sigma: f32) -> f32 {
let radius = (3.0 * sigma).ceil() as i64;
let inv_two_sigma_sq = 1.0 / (2.0 * sigma as f64 * sigma as f64);
let mut sum = 0.0_f64;
let mut sum_sq = 0.0_f64;
for i in -radius..=radius {
let w = (-((i * i) as f64) * inv_two_sigma_sq).exp();
sum += w;
sum_sq += w * w;
}
(sum_sq / (sum * sum)) as f32
}
pub(super) 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 median = median_f32(&mut values);
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 var_sum: f64 = low_half
.iter()
.map(|&v| ((v - median) as f64).powi(2))
.sum();
sigma = (var_sum / low_half.len() as f64).sqrt() as f32;
if sigma < 1e-10 {
break;
}
let lo = median - config.sigma_clip_factor * sigma;
let before = low_half.len();
low_half.retain(|&v| v >= lo);
if low_half.len() == before {
break; }
}
(median, sigma)
}
struct RawCentroid {
x_px: f32,
y_px: f32,
mass: f32,
cov: crate::Matrix2,
}
#[allow(clippy::too_many_arguments)]
fn compute_blob_centroids(
gray: &[f32],
raw: &[f32],
thresh_src: &[f32],
mask_threshold: f32,
regions: &runs::RunRegions,
width: u32,
height: u32,
config: &CentroidExtractionConfig,
) -> Vec<RawCentroid> {
let w = width as usize;
let h = height as usize;
let (offsets, order) = regions.group_by_region();
let mut annulus_vals: Vec<f32> = Vec::new();
let mut maxima: Vec<(f32, usize, usize)> = Vec::new();
let mut kept: Vec<(usize, usize)> = Vec::new();
let mut out: Vec<RawCentroid> = Vec::new();
'region: for k in 0..regions.n_regions {
let region_runs = &order[offsets[k] as usize..offsets[k + 1] as usize];
let pixel_count: usize = region_runs
.iter()
.map(|&i| regions.runs[i as usize].len())
.sum();
if pixel_count < config.min_pixels || pixel_count > config.max_pixels {
continue;
}
let mut min_row = usize::MAX;
let mut max_row = 0usize;
let mut min_col = usize::MAX;
let mut max_col = 0usize;
for &i in region_runs {
let run = regions.runs[i as usize];
min_row = min_row.min(run.row as usize);
max_row = max_row.max(run.row as usize);
min_col = min_col.min(run.c0 as usize);
max_col = max_col.max(run.c1 as usize);
}
let m = config.border_margin as usize;
if m > 0 && (min_row < m || min_col < m || max_row >= h - m || max_col >= w - m) {
continue;
}
let ref_col = min_col;
let ref_row = min_row;
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 thresh_src[i] <= mask_threshold {
annulus_vals.push(gray[i]);
}
}
}
let local_bg = median_f32(&mut annulus_vals) as f64;
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 &i in region_runs {
let run = regions.runs[i as usize];
let r = run.row as usize;
let row_off = r * w;
for c in run.c0 as usize..=run.c1 as usize {
let raw = gray[row_off + c];
if raw > peak_val {
peak_val = raw;
peak_col = c;
peak_row = r;
}
let intensity = (raw 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 {
continue;
}
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;
if let Some(max_elong) = config.max_elongation {
if elongation_from_cov(cxx, cyy, cxy) > max_elong {
continue;
}
}
let raw_peak = raw[peak_row * w + peak_col];
let saturated = config.saturation_level.is_some_and(|s| raw_peak >= s);
if config.deblend == DeblendMode::Reject && !saturated {
let thresh = local_bg + 0.3 * (peak_val as f64 - local_bg);
maxima.clear();
for &i in region_runs {
let run = regions.runs[i as usize];
let r = run.row as usize;
let row_off = r * w;
for c in run.c0 as usize..=run.c1 as usize {
let v = gray[row_off + c];
if (v as f64) <= thresh {
continue;
}
let mut is_max = true;
'nb: for dr in -1..=1_isize {
for dc in -1..=1_isize {
if dr == 0 && dc == 0 {
continue;
}
let rr = r as isize + dr;
let cc = c as isize + dc;
if rr < 0 || cc < 0 || rr >= h as isize || cc >= w as isize {
continue;
}
if gray[rr as usize * w + cc as usize] >= v {
is_max = false;
break 'nb;
}
}
}
if is_max {
maxima.push((v, c, r));
}
}
}
maxima.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
kept.clear();
for &(_, c, r) in &maxima {
let distinct = kept.iter().all(|&(kc, kr)| {
let dx = c as f64 - kc as f64;
let dy = r as f64 - kr as f64;
dx * dx + dy * dy > 4.0
});
if distinct {
kept.push((c, r));
if kept.len() > 1 {
continue 'region;
}
}
}
}
let (pc, pr) = (peak_col, peak_row);
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 - local_bg
};
if let Some(max_sharp) = config.max_sharpness {
if let Some(s) = peak_sharpness((pc, pr), (w, h), v) {
if s > max_sharp as f64 {
continue;
}
}
}
let mut final_x = xbar;
let mut final_y = ybar;
if !saturated {
if let Some((qx, qy)) =
accepted_peak_refine(pixel_count, (pc, pr), (w, h), (xbar, ybar), v)
{
final_x = qx;
final_y = qy;
}
}
out.push(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]]),
});
}
out
}