use crate::{Coordinate, CoordinateF64, Rectangle, Size};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlobMeasurements {
pub area: u64,
pub bbox_min: Coordinate,
pub bbox_max_inclusive: Coordinate,
pub sum_x: u64,
pub sum_y: u64,
pub sum_x2: u64,
pub sum_y2: u64,
pub sum_xy: u64,
pub perimeter: u64,
}
impl BlobMeasurements {
#[inline]
pub(super) fn from_seed(at: Coordinate, is_boundary: bool) -> Self {
let (xu, yu) = (at.x as u64, at.y as u64);
Self {
area: 1,
bbox_min: at,
bbox_max_inclusive: at,
sum_x: xu,
sum_y: yu,
sum_x2: xu * xu,
sum_y2: yu * yu,
sum_xy: xu * yu,
perimeter: is_boundary as u64,
}
}
#[inline]
pub(super) fn extend(&mut self, at: Coordinate, is_boundary: bool) {
self.area += 1;
if at.x < self.bbox_min.x {
self.bbox_min.x = at.x;
}
if at.y < self.bbox_min.y {
self.bbox_min.y = at.y;
}
if at.x > self.bbox_max_inclusive.x {
self.bbox_max_inclusive.x = at.x;
}
if at.y > self.bbox_max_inclusive.y {
self.bbox_max_inclusive.y = at.y;
}
let (xu, yu) = (at.x as u64, at.y as u64);
let (x2, y2, xy) = (xu * xu, yu * yu, xu * yu);
debug_assert!(
self.sum_x2.checked_add(x2).is_some()
&& self.sum_y2.checked_add(y2).is_some()
&& self.sum_xy.checked_add(xy).is_some(),
"BlobMeasurements second-moment sum overflowed u64 \
(image dimension beyond the documented ~46 000 px bound)"
);
self.sum_x += xu;
self.sum_y += yu;
self.sum_x2 += x2;
self.sum_y2 += y2;
self.sum_xy += xy;
self.perimeter += is_boundary as u64;
}
pub fn centroid(&self) -> CoordinateF64 {
let inv = 1.0 / self.area as f64;
CoordinateF64::new(self.sum_x as f64 * inv, self.sum_y as f64 * inv)
}
pub fn bbox(&self) -> Rectangle {
let w = self.bbox_max_inclusive.x - self.bbox_min.x + 1;
let h = self.bbox_max_inclusive.y - self.bbox_min.y + 1;
Rectangle::new(self.bbox_min, Size::new(w, h))
}
pub fn central_moments(&self) -> (f64, f64, f64) {
let inv = 1.0 / self.area as f64;
let xbar = self.sum_x as f64 * inv;
let ybar = self.sum_y as f64 * inv;
let mu20 = self.sum_x2 as f64 * inv - xbar * xbar;
let mu02 = self.sum_y2 as f64 * inv - ybar * ybar;
let mu11 = self.sum_xy as f64 * inv - xbar * ybar;
(mu20, mu02, mu11)
}
pub fn equivalent_diameter(&self) -> f64 {
2.0 * (self.area as f64 / std::f64::consts::PI).sqrt()
}
pub fn orientation(&self) -> f64 {
let (mu20, mu02, mu11) = self.central_moments();
0.5 * (2.0 * mu11).atan2(mu20 - mu02)
}
pub fn eccentricity(&self) -> f64 {
let (mu20, mu02, mu11) = self.central_moments();
let avg = 0.5 * (mu20 + mu02);
let diff = 0.5 * (mu20 - mu02);
let disc = (diff * diff + mu11 * mu11).sqrt();
let l1 = avg + disc; let l2 = avg - disc; if l1 <= 0.0 {
return 0.0;
}
(1.0 - l2 / l1).max(0.0).sqrt()
}
pub fn circularity(&self) -> f64 {
if self.perimeter == 0 {
return 0.0;
}
let p = self.perimeter as f64;
4.0 * std::f64::consts::PI * self.area as f64 / (p * p)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn from_pixels(pixels: &[(usize, usize)]) -> BlobMeasurements {
use std::collections::HashSet;
let set: HashSet<(usize, usize)> = pixels.iter().copied().collect();
let is_boundary = |x: usize, y: usize| {
let n4 = [
x.checked_sub(1).map(|nx| (nx, y)),
Some((x + 1, y)),
y.checked_sub(1).map(|ny| (x, ny)),
Some((x, y + 1)),
];
n4.iter().any(|n| match n {
Some(p) => !set.contains(p),
None => true,
})
};
let mut it = pixels.iter();
let &(x0, y0) = it.next().expect("at least one pixel");
let mut m = BlobMeasurements::from_seed(Coordinate::new(x0, y0), is_boundary(x0, y0));
for &(x, y) in it {
m.extend(Coordinate::new(x, y), is_boundary(x, y));
}
m
}
fn square(n: usize) -> Vec<(usize, usize)> {
let mut v = Vec::with_capacity(n * n);
for y in 0..n {
for x in 0..n {
v.push((x, y));
}
}
v
}
#[test]
fn from_seed_single_pixel_is_finite() {
let m = BlobMeasurements::from_seed(Coordinate::new(3, 5), true);
assert_eq!(m.area, 1);
assert_eq!(m.perimeter, 1);
assert_eq!(m.sum_x2, 9);
assert_eq!(m.sum_y2, 25);
assert_eq!(m.sum_xy, 15);
assert_eq!(m.centroid(), CoordinateF64::new(3.0, 5.0));
assert_eq!(m.eccentricity(), 0.0);
assert_eq!(m.orientation(), 0.0);
assert!(m.circularity().is_finite());
let (mu20, mu02, mu11) = m.central_moments();
assert_eq!((mu20, mu02, mu11), (0.0, 0.0, 0.0));
}
#[test]
fn perimeter_of_square_is_4n_minus_4() {
for n in 1..=10usize {
let m = from_pixels(&square(n));
let expected = if n == 1 { 1 } else { (4 * n - 4) as u64 };
assert_eq!(m.perimeter, expected, "N={n}");
}
}
#[test]
fn equivalent_diameter_of_known_area() {
let m = from_pixels(&square(10)); assert_eq!(m.area, 100);
let expected = 2.0 * (100.0 / PI).sqrt();
assert!((m.equivalent_diameter() - expected).abs() < 1e-12);
}
#[test]
fn square_circularity_below_one() {
let m = from_pixels(&square(40));
let c = m.circularity();
assert!(c < 1.0, "circularity {c} should be < 1");
assert!((c - PI / 4.0).abs() < 0.05, "circularity {c} ≈ π/4");
}
#[test]
fn horizontal_bar_orientation_zero() {
let pixels: Vec<(usize, usize)> = (0..11).map(|x| (x, 0)).collect();
let m = from_pixels(&pixels);
assert!(m.orientation().abs() < 1e-9, "got {}", m.orientation());
}
#[test]
fn vertical_bar_orientation_half_pi() {
let pixels: Vec<(usize, usize)> = (0..11).map(|y| (0, y)).collect();
let m = from_pixels(&pixels);
assert!(
(m.orientation().abs() - PI / 2.0).abs() < 1e-9,
"got {}",
m.orientation()
);
}
#[test]
fn diagonal_bar_orientation_sign() {
let pixels: Vec<(usize, usize)> = (0..11).map(|i| (i, i)).collect();
let m = from_pixels(&pixels);
assert!(
(m.orientation() - PI / 4.0).abs() < 1e-9,
"got {}",
m.orientation()
);
let pixels: Vec<(usize, usize)> = (0..11).map(|i| (10 - i, i)).collect();
let m = from_pixels(&pixels);
assert!(
(m.orientation() + PI / 4.0).abs() < 1e-9,
"got {}",
m.orientation()
);
}
#[test]
fn line_eccentricity_is_exactly_one() {
let pixels: Vec<(usize, usize)> = (0..50).map(|x| (x, 0)).collect();
let m = from_pixels(&pixels);
assert_eq!(m.eccentricity(), 1.0);
let pixels: Vec<(usize, usize)> = (0..50).map(|y| (0, y)).collect();
let m = from_pixels(&pixels);
assert_eq!(m.eccentricity(), 1.0);
}
#[test]
fn disc_eccentricity_near_zero_and_circularity_near_one() {
let (cx, cy, r) = (25i64, 25i64, 20i64);
let mut pixels = Vec::new();
for y in 0..=50i64 {
for x in 0..=50i64 {
let (dx, dy) = (x - cx, y - cy);
if dx * dx + dy * dy <= r * r {
pixels.push((x as usize, y as usize));
}
}
}
let m = from_pixels(&pixels);
assert!(m.eccentricity() < 0.05, "ecc {}", m.eccentricity());
let c = m.circularity();
assert!(c > 1.1 && c < 1.4, "circularity {c}");
}
#[test]
fn moments_match_brute_force() {
let mut pixels = Vec::new();
let mut state: u64 = 0x1234_5678;
for y in 0..30usize {
for x in 0..30usize {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
if state & 1 == 1 {
pixels.push((x, y));
}
}
}
let m = from_pixels(&pixels);
let (mut sx, mut sy, mut sx2, mut sy2, mut sxy) = (0u64, 0u64, 0u64, 0u64, 0u64);
for &(x, y) in &pixels {
let (x, y) = (x as u64, y as u64);
sx += x;
sy += y;
sx2 += x * x;
sy2 += y * y;
sxy += x * y;
}
assert_eq!(m.area, pixels.len() as u64);
assert_eq!((m.sum_x, m.sum_y), (sx, sy));
assert_eq!((m.sum_x2, m.sum_y2, m.sum_xy), (sx2, sy2, sxy));
}
}