use crate::CoordinateF64;
use crate::common::AxialOrientation;
use crate::image::RasterImage;
use crate::pixel::SingleChannel;
use super::summary::StatisticsChannel;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ImageMoments {
pub m00: f64,
pub m10: f64,
pub m01: f64,
pub m20: f64,
pub m11: f64,
pub m02: f64,
pub m30: f64,
pub m21: f64,
pub m12: f64,
pub m03: f64,
}
impl ImageMoments {
#[must_use]
pub fn centroid(&self) -> Option<CoordinateF64> {
(self.m00 != 0.0).then(|| CoordinateF64::new(self.m10 / self.m00, self.m01 / self.m00))
}
#[must_use]
pub fn central_moments(&self) -> Option<CentralMoments> {
let centroid = self.centroid()?;
let (x, y) = (centroid.x, centroid.y);
Some(CentralMoments {
mu00: self.m00,
mu20: self.m20 - x * self.m10,
mu11: self.m11 - x * self.m01,
mu02: self.m02 - y * self.m01,
mu30: self.m30 - 3.0 * x * self.m20 + 2.0 * x * x * self.m10,
mu21: self.m21 - 2.0 * x * self.m11 - y * self.m20 + 2.0 * x * x * self.m01,
mu12: self.m12 - 2.0 * y * self.m11 - x * self.m02 + 2.0 * y * y * self.m10,
mu03: self.m03 - 3.0 * y * self.m02 + 2.0 * y * y * self.m01,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct CentralMoments {
pub mu00: f64,
pub mu20: f64,
pub mu11: f64,
pub mu02: f64,
pub mu30: f64,
pub mu21: f64,
pub mu12: f64,
pub mu03: f64,
}
impl CentralMoments {
#[must_use]
pub fn orientation(&self) -> AxialOrientation {
axis_orientation(self.mu20, self.mu02, self.mu11)
}
#[must_use]
pub fn eccentricity(&self) -> f64 {
axis_eccentricity(self.mu20, self.mu02, self.mu11)
}
#[must_use]
pub fn normalized(&self) -> Option<NormalizedMoments> {
if self.mu00.is_nan() || self.mu00 <= 0.0 {
return None;
}
let second = self.mu00 * self.mu00;
let third = second * self.mu00.sqrt();
Some(NormalizedMoments {
eta20: self.mu20 / second,
eta11: self.mu11 / second,
eta02: self.mu02 / second,
eta30: self.mu30 / third,
eta21: self.mu21 / third,
eta12: self.mu12 / third,
eta03: self.mu03 / third,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct NormalizedMoments {
pub eta20: f64,
pub eta11: f64,
pub eta02: f64,
pub eta30: f64,
pub eta21: f64,
pub eta12: f64,
pub eta03: f64,
}
impl NormalizedMoments {
#[must_use]
pub fn hu(&self) -> [f64; 7] {
let (n20, n11, n02) = (self.eta20, self.eta11, self.eta02);
let (n30, n21, n12, n03) = (self.eta30, self.eta21, self.eta12, self.eta03);
let sum_x = n30 + n12;
let sum_y = n21 + n03;
let skew_x = n30 - 3.0 * n12;
let skew_y = 3.0 * n21 - n03;
let (sum_x2, sum_y2) = (sum_x * sum_x, sum_y * sum_y);
[
n20 + n02,
(n20 - n02) * (n20 - n02) + 4.0 * n11 * n11,
skew_x * skew_x + skew_y * skew_y,
sum_x2 + sum_y2,
skew_x * sum_x * (sum_x2 - 3.0 * sum_y2) + skew_y * sum_y * (3.0 * sum_x2 - sum_y2),
(n20 - n02) * (sum_x2 - sum_y2) + 4.0 * n11 * sum_x * sum_y,
skew_y * sum_x * (sum_x2 - 3.0 * sum_y2) - skew_x * sum_y * (3.0 * sum_x2 - sum_y2),
]
}
}
#[inline]
pub(crate) fn axis_orientation(mu20: f64, mu02: f64, mu11: f64) -> AxialOrientation {
AxialOrientation::from_half_atan2(2.0 * mu11, mu20 - mu02)
}
#[inline]
pub(crate) fn axis_eccentricity(mu20: f64, mu02: f64, mu11: f64) -> f64 {
let average = 0.5 * (mu20 + mu02);
let difference = 0.5 * (mu20 - mu02);
let discriminant = (difference * difference + mu11 * mu11).sqrt();
let larger = average + discriminant;
let smaller = average - discriminant;
if larger <= 0.0 {
return 0.0;
}
(1.0 - smaller / larger).clamp(0.0, 1.0).sqrt()
}
#[must_use]
pub fn image_moments<I, P>(image: &I) -> ImageMoments
where
I: RasterImage<Pixel = P>,
P: SingleChannel,
P::Channel: StatisticsChannel,
{
let mut moments = ImageMoments::default();
for y in 0..image.height() {
let (mut s0, mut s1, mut s2, mut s3) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
for (x, pixel) in image.row(y).iter().enumerate() {
let value = pixel.channel(0).to_f64();
let x = x as f64;
let vx = value * x;
let vx2 = vx * x;
s0 += value;
s1 += vx;
s2 += vx2;
s3 += vx2 * x;
}
let y = y as f64;
let y2 = y * y;
moments.m00 += s0;
moments.m10 += s1;
moments.m01 += y * s0;
moments.m20 += s2;
moments.m11 += y * s1;
moments.m02 += y2 * s0;
moments.m30 += s3;
moments.m21 += y * s2;
moments.m12 += y2 * s1;
moments.m03 += y2 * y * s0;
}
moments
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::{Image, ImageView};
use crate::pixel::{Mono8, MonoF32, MonoF64};
fn brute_force(image: &Image<MonoF64>) -> (ImageMoments, CentralMoments) {
let mut raw = ImageMoments::default();
for y in 0..image.height() {
for x in 0..image.width() {
let i = image.row(y)[x].0;
let (xf, yf) = (x as f64, y as f64);
raw.m00 += i;
raw.m10 += xf * i;
raw.m01 += yf * i;
raw.m20 += xf * xf * i;
raw.m11 += xf * yf * i;
raw.m02 += yf * yf * i;
raw.m30 += xf * xf * xf * i;
raw.m21 += xf * xf * yf * i;
raw.m12 += xf * yf * yf * i;
raw.m03 += yf * yf * yf * i;
}
}
let (xb, yb) = (raw.m10 / raw.m00, raw.m01 / raw.m00);
let mut central = CentralMoments {
mu00: raw.m00,
..CentralMoments::default()
};
for y in 0..image.height() {
for x in 0..image.width() {
let i = image.row(y)[x].0;
let (dx, dy) = (x as f64 - xb, y as f64 - yb);
central.mu20 += dx * dx * i;
central.mu11 += dx * dy * i;
central.mu02 += dy * dy * i;
central.mu30 += dx * dx * dx * i;
central.mu21 += dx * dx * dy * i;
central.mu12 += dx * dy * dy * i;
central.mu03 += dy * dy * dy * i;
}
}
(raw, central)
}
fn texture(width: usize, height: usize) -> Image<MonoF64> {
Image::generate(width, height, |x, y| {
MonoF64::new(((x * 7 + y * 13) % 23) as f64 * 0.5 + (x % 3) as f64)
})
}
#[test]
fn the_row_factored_pass_matches_the_definition() {
let image = texture(19, 23);
let (expected, _) = brute_force(&image);
let actual = image_moments(&image);
for (name, a, b) in [
("m00", actual.m00, expected.m00),
("m10", actual.m10, expected.m10),
("m01", actual.m01, expected.m01),
("m20", actual.m20, expected.m20),
("m11", actual.m11, expected.m11),
("m02", actual.m02, expected.m02),
("m30", actual.m30, expected.m30),
("m21", actual.m21, expected.m21),
("m12", actual.m12, expected.m12),
("m03", actual.m03, expected.m03),
] {
assert!(
(a - b).abs() <= 1e-9 * b.abs().max(1.0),
"{name}: {a} vs {b}"
);
}
}
#[test]
fn the_central_identities_match_a_second_pass_about_the_centroid() {
let image = texture(21, 17);
let (_, expected) = brute_force(&image);
let actual = image_moments(&image).central_moments().unwrap();
for (name, a, b) in [
("mu00", actual.mu00, expected.mu00),
("mu20", actual.mu20, expected.mu20),
("mu11", actual.mu11, expected.mu11),
("mu02", actual.mu02, expected.mu02),
("mu30", actual.mu30, expected.mu30),
("mu21", actual.mu21, expected.mu21),
("mu12", actual.mu12, expected.mu12),
("mu03", actual.mu03, expected.mu03),
] {
assert!(
(a - b).abs() <= 1e-6 * b.abs().max(1.0),
"{name}: {a} vs {b}"
);
}
}
#[test]
fn first_order_central_moments_are_zero_by_construction() {
let image = texture(13, 11);
let raw = image_moments(&image);
let centroid = raw.centroid().unwrap();
let mu10 = raw.m10 - centroid.x * raw.m00;
let mu01 = raw.m01 - centroid.y * raw.m00;
assert!(mu10.abs() <= 1e-9 * raw.m10.abs(), "{mu10}");
assert!(mu01.abs() <= 1e-9 * raw.m01.abs(), "{mu01}");
}
#[test]
fn a_black_image_has_no_centroid_and_no_central_moments() {
let image = Image::fill(8, 8, MonoF32::new(0.0));
let raw = image_moments(&image);
assert_eq!(raw.m00, 0.0);
assert_eq!(raw.centroid(), None);
assert_eq!(raw.central_moments(), None);
}
#[test]
fn an_empty_image_has_no_centroid() {
let image: Image<MonoF32> = Image::generate(0, 0, |_, _| MonoF32::new(0.0));
let raw = image_moments(&image);
assert_eq!(raw.m00, 0.0);
assert_eq!(raw.centroid(), None);
}
#[test]
fn the_centroid_of_a_symmetric_block_is_its_centre() {
let image = Image::generate(16, 16, |x, y| {
Mono8::new(if (4..12).contains(&x) && (4..12).contains(&y) {
200
} else {
0
})
});
let centroid = image_moments(&image).centroid().unwrap();
assert!((centroid.x - 7.5).abs() < 1e-12, "{}", centroid.x);
assert!((centroid.y - 7.5).abs() < 1e-12, "{}", centroid.y);
}
#[test]
fn a_nan_sample_poisons_every_moment() {
let image = Image::generate(4, 4, |x, y| {
MonoF32::new(if (x, y) == (2, 1) { f32::NAN } else { 1.0 })
});
let raw = image_moments(&image);
assert!(raw.m00.is_nan());
assert!(raw.m21.is_nan());
let centroid = raw.centroid().unwrap();
assert!(centroid.x.is_nan() && centroid.y.is_nan());
}
#[test]
fn translating_the_image_leaves_the_central_moments_alone() {
let shape = |ox: usize, oy: usize| {
Image::generate(40, 40, |x, y| {
let inside = (ox..ox + 6).contains(&x) && (oy..oy + 14).contains(&y);
MonoF32::new(if inside { 1.0 } else { 0.0 })
})
};
let a = image_moments(&shape(3, 3)).central_moments().unwrap();
let b = image_moments(&shape(20, 17)).central_moments().unwrap();
for (name, x, y) in [
("mu20", a.mu20, b.mu20),
("mu11", a.mu11, b.mu11),
("mu02", a.mu02, b.mu02),
("mu30", a.mu30, b.mu30),
("mu03", a.mu03, b.mu03),
] {
assert!((x - y).abs() < 1e-6, "{name}: {x} vs {y}");
}
}
#[test]
fn normalization_needs_positive_total_intensity() {
let black = CentralMoments::default();
assert_eq!(black.normalized(), None);
let negative = CentralMoments {
mu00: -4.0,
..CentralMoments::default()
};
assert_eq!(negative.normalized(), None);
let nan = CentralMoments {
mu00: f64::NAN,
..CentralMoments::default()
};
assert_eq!(nan.normalized(), None, "NaN is not > 0");
}
#[test]
fn normalization_matches_the_closed_form_and_is_scale_invariant_up_to_rasterisation() {
let eta = |width: usize, height: usize, side: usize| {
let image = Image::generate(side, side, |x, y| {
MonoF32::new(if x < width && y < height { 1.0 } else { 0.0 })
});
image_moments(&image)
.central_moments()
.and_then(|c| c.normalized())
.unwrap()
};
let closed_form = |w: f64, h: f64| (w * w - 1.0) / (12.0 * w * h);
let small = eta(20, 40, 64);
let large = eta(60, 120, 128);
assert!(
(small.eta20 - closed_form(20.0, 40.0)).abs() < 1e-12,
"{}",
small.eta20
);
assert!(
(large.eta20 - closed_form(60.0, 120.0)).abs() < 1e-12,
"{}",
large.eta20
);
let relative = (small.eta20 - large.eta20).abs() / large.eta20;
assert!(relative < 0.005, "{relative}");
assert_eq!(small.eta11, 0.0);
assert_eq!(large.eta11, 0.0);
}
#[test]
fn hu_is_invariant_under_a_quarter_turn() {
const N: usize = 33;
let upright = Image::generate(N, N, |x, y| {
let inside = (6..12).contains(&x) && (6..24).contains(&y)
|| (6..20).contains(&x) && (18..24).contains(&y);
MonoF32::new(if inside { 1.0 } else { 0.0 })
});
let turned = Image::generate(N, N, |x, y| upright.row(N - 1 - x)[y]);
let hu = |image: &Image<MonoF32>| {
image_moments(image)
.central_moments()
.and_then(|c| c.normalized())
.unwrap()
.hu()
};
let (a, b) = (hu(&upright), hu(&turned));
for i in 0..7 {
let scale = a[i].abs().max(b[i].abs()).max(1e-12);
assert!(
(a[i] - b[i]).abs() <= 1e-9 * scale,
"h{}: {} vs {}",
i + 1,
a[i],
b[i]
);
}
}
#[test]
fn hu_h7_changes_sign_under_a_mirror() {
const N: usize = 33;
let upright = Image::generate(N, N, |x, y| {
let inside = (6..12).contains(&x) && (6..24).contains(&y)
|| (6..20).contains(&x) && (18..24).contains(&y);
MonoF32::new(if inside { 1.0 } else { 0.0 })
});
let mirrored = Image::generate(N, N, |x, y| upright.row(y)[N - 1 - x]);
let hu = |image: &Image<MonoF32>| {
image_moments(image)
.central_moments()
.and_then(|c| c.normalized())
.unwrap()
.hu()
};
let (a, b) = (hu(&upright), hu(&mirrored));
for i in 0..6 {
let scale = a[i].abs().max(b[i].abs()).max(1e-12);
assert!((a[i] - b[i]).abs() <= 1e-9 * scale, "h{}", i + 1);
}
assert!(a[6].abs() > 1e-12, "the fixture must have a nonzero h7");
assert!(
(a[6] + b[6]).abs() <= 1e-9 * a[6].abs(),
"{} vs {}",
a[6],
b[6]
);
}
#[test]
fn a_horizontal_bar_is_eccentric_and_axis_aligned() {
let image = Image::generate(32, 32, |x, y| {
MonoF32::new(if (4..28).contains(&x) && (15..17).contains(&y) {
1.0
} else {
0.0
})
});
let central = image_moments(&image).central_moments().unwrap();
assert!(central.eccentricity() > 0.98, "{}", central.eccentricity());
assert!(central.orientation().radians().abs() < 1e-9);
}
#[test]
fn a_symmetric_disc_is_barely_eccentric() {
let image = Image::generate(41, 41, |x, y| {
let (dx, dy) = (x as f64 - 20.0, y as f64 - 20.0);
MonoF32::new(if dx * dx + dy * dy <= 15.0 * 15.0 {
1.0
} else {
0.0
})
});
let central = image_moments(&image).central_moments().unwrap();
assert!(central.eccentricity() < 0.05, "{}", central.eccentricity());
}
#[test]
fn a_single_bright_pixel_is_degenerate_not_nan() {
let image = Image::generate(8, 8, |x, y| {
MonoF32::new(if (x, y) == (3, 4) { 1.0 } else { 0.0 })
});
let central = image_moments(&image).central_moments().unwrap();
assert_eq!(central.mu20, 0.0);
assert_eq!(central.mu02, 0.0);
assert_eq!(central.eccentricity(), 0.0);
}
#[test]
fn a_collinear_run_at_a_large_offset_stays_in_the_documented_range() {
let image: Image<MonoF32> = Image::generate(65_536, 5, |x, _| {
MonoF32::new(if x == 65_535 { 1.0 } else { 0.0 })
});
let central = image_moments(&image).central_moments().unwrap();
let ecc = central.eccentricity();
assert!(ecc <= 1.0, "eccentricity {ecc} escapes [0, 1]");
assert!(ecc > 0.999, "a collinear run is maximally eccentric: {ecc}");
}
#[test]
fn integer_and_float_inputs_agree_on_the_same_shape() {
let eight = Image::generate(24, 24, |x, y| {
Mono8::new(if (5..15).contains(&x) && (7..19).contains(&y) {
255
} else {
0
})
});
let float = Image::generate(24, 24, |x, y| {
MonoF32::new(if (5..15).contains(&x) && (7..19).contains(&y) {
255.0
} else {
0.0
})
});
let a = image_moments(&eight);
let b = image_moments(&float);
assert_eq!(a.m00, b.m00);
assert_eq!(a.m21, b.m21);
}
}