use crate::error::Error;
use crate::image::RasterImage;
use crate::pixel::SingleChannel;
use crate::transform::{nms_sector, nms_sector_from_gradient};
use crate::{Coordinate, CoordinateF64, Offset, Orientation};
pub use crate::common::Extremum;
#[must_use]
pub fn parabola_vertex(before: f64, at: f64, after: f64, kind: Extremum) -> Option<f64> {
let curvature = before + after - 2.0 * at;
let curves_toward_kind = match kind {
Extremum::Maximum => curvature < 0.0,
Extremum::Minimum => curvature > 0.0,
};
if !curves_toward_kind {
return None;
}
let offset = 0.5 * (before - after) / curvature;
let contained = offset.abs() <= 1.0;
if !contained {
return None;
}
Some(offset)
}
#[must_use]
pub fn interpolate_peak<I, P>(surface: &I, at: Coordinate, kind: Extremum) -> Option<CoordinateF64>
where
I: RasterImage<Pixel = P>,
P: SingleChannel,
f64: From<P::Channel>,
{
let (w, h) = (surface.width(), surface.height());
if at.x == 0 || at.y == 0 || at.x >= w.saturating_sub(1) || at.y >= h.saturating_sub(1) {
return None;
}
let mut s = [[0.0f64; 3]; 3];
for (row, y) in s.iter_mut().zip(at.y - 1..=at.y + 1) {
let source = surface.row(y);
for (sample, x) in row.iter_mut().zip(at.x - 1..=at.x + 1) {
*sample = f64::from(source[x].channel(0));
}
}
let (dx, dy) = quadratic_vertex(&s, kind)?;
Some(CoordinateF64::new(at.x as f64 + dx, at.y as f64 + dy))
}
fn quadratic_vertex(s: &[[f64; 3]; 3], kind: Extremum) -> Option<(f64, f64)> {
let gx = (s[1][2] - s[1][0]) / 2.0;
let gy = (s[2][1] - s[0][1]) / 2.0;
let hxx = s[1][2] - 2.0 * s[1][1] + s[1][0];
let hyy = s[2][1] - 2.0 * s[1][1] + s[0][1];
let hxy = (s[2][2] - s[2][0] - s[0][2] + s[0][0]) / 4.0;
let determinant = hxx * hyy - hxy * hxy;
let definite = determinant > 0.0
&& match kind {
Extremum::Maximum => hxx < 0.0,
Extremum::Minimum => hxx > 0.0,
};
if !definite {
return None;
}
let dx = -(hyy * gx - hxy * gy) / determinant;
let dy = -(hxx * gy - hxy * gx) / determinant;
let contained = dx.abs() <= 1.0 && dy.abs() <= 1.0;
if !contained {
return None;
}
Some((dx, dy))
}
#[must_use]
pub fn interpolate_peak_along<I, P>(
magnitude: &I,
at: Coordinate,
gradient: Orientation,
) -> Option<CoordinateF64>
where
I: RasterImage<Pixel = P>,
P: SingleChannel,
f64: From<P::Channel>,
{
let theta: f64 = gradient.radians().into();
fit_along_step(magnitude, at, nms_sector(theta))
}
pub fn interpolate_ridge_points<IM, IX, IY, P>(
sites: impl IntoIterator<Item = Coordinate>,
magnitude: &IM,
gx: &IX,
gy: &IY,
) -> Result<Vec<Option<CoordinateF64>>, Error>
where
IM: RasterImage<Pixel = P>,
IX: RasterImage<Pixel = P>,
IY: RasterImage<Pixel = P>,
P: SingleChannel,
f64: From<P::Channel>,
{
if magnitude.size() != gx.size() {
return Err(Error::SizeMismatch {
expected: magnitude.size(),
actual: gx.size(),
});
}
if magnitude.size() != gy.size() {
return Err(Error::SizeMismatch {
expected: magnitude.size(),
actual: gy.size(),
});
}
let (w, h) = (magnitude.width(), magnitude.height());
Ok(sites
.into_iter()
.map(|at| {
if at.x >= w || at.y >= h {
return None;
}
let step = nms_sector_from_gradient(
f64::from(gx.row(at.y)[at.x].channel(0)),
f64::from(gy.row(at.y)[at.x].channel(0)),
);
fit_along_step(magnitude, at, step)
})
.collect())
}
fn fit_along_step<I, P>(magnitude: &I, at: Coordinate, step: Offset) -> Option<CoordinateF64>
where
I: RasterImage<Pixel = P>,
P: SingleChannel,
f64: From<P::Channel>,
{
if at.x >= magnitude.width() || at.y >= magnitude.height() {
return None;
}
let sample = |sign: i32| -> Option<f64> {
let n = at.checked_add(Offset::new(sign * step.dx, sign * step.dy))?;
if n.x >= magnitude.width() || n.y >= magnitude.height() {
return None;
}
Some(f64::from(magnitude.row(n.y)[n.x].channel(0)))
};
let before = sample(-1)?;
let after = sample(1)?;
let centre = f64::from(magnitude.row(at.y)[at.x].channel(0));
let offset = parabola_vertex(before, centre, after, Extremum::Maximum)?;
Some(CoordinateF64::new(
at.x as f64 + offset * step.dx as f64,
at.y as f64 + offset * step.dy as f64,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::Image;
use crate::pixel::{MonoF32, MonoF64};
use crate::sigma;
fn paraboloid(w: usize, h: usize, cx: f64, cy: f64) -> Image<MonoF64> {
Image::generate(w, h, |x, y| {
let (dx, dy) = (x as f64 - cx, y as f64 - cy);
MonoF64::new(1.0 - dx * dx - dy * dy)
})
}
#[test]
fn a_symmetric_triple_puts_the_vertex_on_the_centre() {
assert_eq!(parabola_vertex(0.0, 1.0, 0.0, Extremum::Maximum), Some(0.0));
assert_eq!(parabola_vertex(1.0, 0.0, 1.0, Extremum::Minimum), Some(0.0));
}
#[test]
fn the_vertex_leans_toward_the_larger_neighbour() {
let right = parabola_vertex(1.0, 2.0, 1.5, Extremum::Maximum).unwrap();
let left = parabola_vertex(1.5, 2.0, 1.0, Extremum::Maximum).unwrap();
assert!(right > 0.0 && left < 0.0);
assert!(
(right + left).abs() < 1e-12,
"mirror images: {right} {left}"
);
}
#[test]
fn a_centred_crest_keeps_the_vertex_within_half_a_step() {
assert_eq!(parabola_vertex(0.0, 1.0, 1.0, Extremum::Maximum), Some(0.5));
assert_eq!(
parabola_vertex(1.0, 1.0, 0.0, Extremum::Maximum),
Some(-0.5)
);
for after in [0.0, 0.25, 0.5, 0.75, 0.9] {
let offset = parabola_vertex(0.0, 1.0, after, Extremum::Maximum).unwrap();
assert!(offset.abs() < 0.5, "after = {after} gave {offset}");
}
}
#[test]
fn a_crest_one_pixel_over_is_still_fitted() {
let offset = parabola_vertex(2.0, 1.9, 0.0, Extremum::Maximum).unwrap();
assert!((-1.0..-0.5).contains(&offset), "{offset}");
let mirrored = parabola_vertex(0.0, 1.9, 2.0, Extremum::Maximum).unwrap();
assert!((0.5..1.0).contains(&mirrored), "{mirrored}");
}
#[test]
fn a_vertex_the_samples_do_not_bracket_is_refused() {
assert_eq!(parabola_vertex(0.0, 1.0, 1.5, Extremum::Maximum), None);
assert_eq!(parabola_vertex(1.5, 1.0, 0.0, Extremum::Maximum), None);
assert_eq!(parabola_vertex(0.0, 1.0, 1.5, Extremum::Minimum), None);
}
#[test]
fn a_straight_run_has_no_curvature_to_fit() {
assert_eq!(parabola_vertex(0.0, 1.0, 2.0, Extremum::Maximum), None);
assert_eq!(parabola_vertex(2.0, 1.0, 0.0, Extremum::Minimum), None);
}
#[test]
fn the_requested_kind_is_enforced() {
assert_eq!(parabola_vertex(0.0, 1.0, 0.5, Extremum::Minimum), None);
assert_eq!(parabola_vertex(1.0, 0.0, 0.5, Extremum::Maximum), None);
}
#[test]
fn a_flat_triple_has_no_vertex() {
assert_eq!(parabola_vertex(2.0, 2.0, 2.0, Extremum::Maximum), None);
assert_eq!(parabola_vertex(2.0, 2.0, 2.0, Extremum::Minimum), None);
}
#[test]
fn a_nan_sample_is_refused_rather_than_propagated() {
for kind in [Extremum::Maximum, Extremum::Minimum] {
assert_eq!(parabola_vertex(f64::NAN, 1.0, 0.0, kind), None);
assert_eq!(parabola_vertex(0.0, f64::NAN, 0.0, kind), None);
assert_eq!(parabola_vertex(0.0, 1.0, f64::NAN, kind), None);
}
}
#[test]
fn an_infinite_sample_is_refused_rather_than_propagated() {
assert_eq!(
parabola_vertex(f64::INFINITY, 0.0, 0.0, Extremum::Minimum),
None
);
assert_eq!(
parabola_vertex(f64::NEG_INFINITY, 0.0, 0.0, Extremum::Maximum),
None
);
assert_eq!(
parabola_vertex(0.0, 0.0, f64::INFINITY, Extremum::Minimum),
None
);
}
#[test]
fn the_fit_recovers_a_quadratic_surfaces_vertex_exactly() {
for (cx, cy) in [(3.0, 3.0), (3.25, 2.4), (2.6, 3.9), (3.5, 3.5)] {
let surface = paraboloid(7, 7, cx, cy);
let at = Coordinate::new(cx.round() as usize, cy.round() as usize);
let found = interpolate_peak(&surface, at, Extremum::Maximum).unwrap();
assert!(
(found.x - cx).abs() < 1e-9 && (found.y - cy).abs() < 1e-9,
"crest ({cx}, {cy}) recovered as {found:?}",
);
}
}
#[test]
fn the_cross_term_is_part_of_the_fit() {
let (cx, cy) = (4.3, 3.6);
let surface: Image<MonoF64> = Image::generate(9, 9, |x, y| {
let (dx, dy) = (x as f64 - cx, y as f64 - cy);
let (u, v) = ((dx + dy) / 2.0f64.sqrt(), (dx - dy) / 2.0f64.sqrt());
MonoF64::new(1.0 - u * u - 16.0 * v * v)
});
let found = interpolate_peak(&surface, Coordinate::new(4, 4), Extremum::Maximum).unwrap();
assert!(
(found.x - cx).abs() < 1e-9 && (found.y - cy).abs() < 1e-9,
"{found:?}",
);
}
#[test]
fn a_site_on_the_border_has_no_complete_window() {
let surface = paraboloid(5, 5, 0.0, 0.0);
for at in [
Coordinate::new(0, 2),
Coordinate::new(2, 0),
Coordinate::new(4, 2),
Coordinate::new(2, 4),
] {
assert_eq!(interpolate_peak(&surface, at, Extremum::Maximum), None);
}
}
#[test]
fn a_plateau_has_no_isolated_vertex() {
let surface: Image<MonoF32> = Image::fill(5, 5, MonoF32::new(1.0));
assert_eq!(
interpolate_peak(&surface, Coordinate::new(2, 2), Extremum::Maximum),
None
);
}
#[test]
fn a_saddle_is_neither_maximum_nor_minimum() {
let surface: Image<MonoF64> = Image::generate(5, 5, |x, y| {
let (dx, dy) = (x as f64 - 2.0, y as f64 - 2.0);
MonoF64::new(dx * dx - dy * dy)
});
for kind in [Extremum::Maximum, Extremum::Minimum] {
assert_eq!(
interpolate_peak(&surface, Coordinate::new(2, 2), kind),
None
);
}
}
#[test]
fn a_ridge_with_no_curvature_along_it_is_refused() {
let surface: Image<MonoF64> = Image::generate(5, 5, |x, _| {
let dx = x as f64 - 2.0;
MonoF64::new(1.0 - dx * dx)
});
assert_eq!(
interpolate_peak(&surface, Coordinate::new(2, 2), Extremum::Maximum),
None
);
}
#[test]
fn a_vertex_outside_the_sampled_window_is_refused() {
let surface = paraboloid(9, 9, 7.0, 4.0);
assert_eq!(
interpolate_peak(&surface, Coordinate::new(4, 4), Extremum::Maximum),
None
);
}
#[test]
fn the_requested_kind_is_enforced_in_two_dimensions() {
let bowl: Image<MonoF64> = Image::generate(7, 7, |x, y| {
let (dx, dy) = (x as f64 - 3.25, y as f64 - 3.0);
MonoF64::new(dx * dx + dy * dy)
});
let at = Coordinate::new(3, 3);
assert!(interpolate_peak(&bowl, at, Extremum::Minimum).is_some());
assert_eq!(interpolate_peak(&bowl, at, Extremum::Maximum), None);
}
#[test]
fn a_nan_neighbour_refuses_the_fit() {
let surface: Image<MonoF32> = Image::generate(5, 5, |x, y| {
MonoF32::new(if (x, y) == (3, 2) {
f32::NAN
} else if (x, y) == (2, 2) {
1.0
} else {
0.0
})
});
assert_eq!(
interpolate_peak(&surface, Coordinate::new(2, 2), Extremum::Maximum),
None
);
}
#[test]
fn an_infinite_neighbour_refuses_the_fit() {
let surface: Image<MonoF32> = Image::generate(5, 5, |x, y| {
MonoF32::new(if (x, y) == (1, 2) {
f32::INFINITY
} else {
let (dx, dy) = (x as f32 - 2.0, y as f32 - 2.0);
dx * dx + dy * dy
})
});
assert_eq!(
interpolate_peak(&surface, Coordinate::new(2, 2), Extremum::Minimum),
None
);
}
#[test]
fn both_float_accumulator_widths_are_accepted() {
let wide = paraboloid(7, 7, 3.25, 3.0);
let narrow: Image<MonoF32> = Image::generate(7, 7, |x, y| {
let (dx, dy) = (x as f64 - 3.25, y as f64 - 3.0);
MonoF32::new((1.0 - dx * dx - dy * dy) as f32)
});
let at = Coordinate::new(3, 3);
let a = interpolate_peak(&wide, at, Extremum::Maximum).unwrap();
let b = interpolate_peak(&narrow, at, Extremum::Maximum).unwrap();
assert!((a.x - 3.25).abs() < 1e-9, "{a:?}");
assert!((b.x - 3.25).abs() < 1e-3, "{b:?}");
}
fn column_ridge(profile: [f32; 5]) -> Image<MonoF32> {
Image::generate(5, 3, |x, _| MonoF32::new(profile[x]))
}
#[test]
fn the_ridge_fit_runs_along_the_gradient() {
let magnitude = column_ridge([0.0, 1.0, 2.0, 1.5, 0.0]);
let across = Orientation::from_atan2(0.0, 1.0);
let at = interpolate_peak_along(&magnitude, Coordinate::new(2, 1), across).unwrap();
assert!((at.x - (2.0 + 1.0 / 6.0)).abs() < 1e-12, "{at:?}");
assert_eq!(at.y, 1.0, "the fit does not move across its own axis");
}
#[test]
fn opposite_gradients_give_the_same_edge_point() {
let magnitude = column_ridge([0.0, 1.0, 2.0, 1.5, 0.0]);
let east = interpolate_peak_along(
&magnitude,
Coordinate::new(2, 1),
Orientation::from_atan2(0.0, 1.0),
);
let west = interpolate_peak_along(
&magnitude,
Coordinate::new(2, 1),
Orientation::from_atan2(0.0, -1.0),
);
assert_eq!(east, west);
}
#[test]
fn a_diagonal_gradient_moves_the_point_on_both_axes() {
let magnitude: Image<MonoF32> = Image::generate(5, 5, |x, y| {
let d = x as isize + y as isize - 4;
MonoF32::new(match d {
0 => 2.0,
2 => 1.5,
-2 => 1.0,
_ => 0.0,
})
});
let along = Orientation::from_atan2(1.0, 1.0);
let at = interpolate_peak_along(&magnitude, Coordinate::new(2, 2), along).unwrap();
let expected = 2.0 + 1.0 / 6.0;
assert!((at.x - expected).abs() < 1e-12, "{at:?}");
assert!((at.y - expected).abs() < 1e-12, "{at:?}");
}
#[test]
fn a_ridge_site_against_the_border_is_refused() {
let magnitude = column_ridge([2.0, 1.5, 1.0, 0.5, 0.0]);
let across = Orientation::from_atan2(0.0, 1.0);
assert_eq!(
interpolate_peak_along(&magnitude, Coordinate::new(0, 1), across),
None
);
}
#[test]
fn a_thinned_magnitude_leaves_the_point_on_its_pixel() {
let thinned = column_ridge([0.0, 0.0, 2.0, 0.0, 0.0]);
let across = Orientation::from_atan2(0.0, 1.0);
let at = interpolate_peak_along(&thinned, Coordinate::new(2, 1), across).unwrap();
assert_eq!(at, CoordinateF64::new(2.0, 1.0));
let magnitude = column_ridge([0.0, 1.0, 2.0, 1.5, 0.0]);
let moved = interpolate_peak_along(&magnitude, Coordinate::new(2, 1), across).unwrap();
assert!(moved.x > 2.0, "{moved:?}");
}
#[test]
fn ridge_points_take_their_direction_from_the_gradient_pair() {
let magnitude = column_ridge([0.0, 1.0, 2.0, 1.5, 0.0]);
let gx: Image<MonoF32> = Image::fill(5, 3, MonoF32::new(1.0));
let gy: Image<MonoF32> = Image::fill(5, 3, MonoF32::new(0.0));
let sites = [Coordinate::new(2, 0), Coordinate::new(2, 2)];
let points = interpolate_ridge_points(sites, &magnitude, &gx, &gy).unwrap();
let expected = 2.0 + 1.0 / 6.0;
assert_eq!(points.len(), 2);
assert!((points[0].unwrap().x - expected).abs() < 1e-12);
assert!((points[1].unwrap().x - expected).abs() < 1e-12);
}
#[test]
fn ridge_points_stay_aligned_with_their_sites() {
let magnitude = column_ridge([0.0, 1.0, 2.0, 1.5, 0.0]);
let gx: Image<MonoF32> = Image::fill(5, 3, MonoF32::new(1.0));
let gy: Image<MonoF32> = Image::fill(5, 3, MonoF32::new(0.0));
let sites = [
Coordinate::new(2, 1),
Coordinate::new(0, 1),
Coordinate::new(9, 9),
Coordinate::new(1, 1),
];
let points = interpolate_ridge_points(sites, &magnitude, &gx, &gy).unwrap();
assert_eq!(points.len(), 4);
assert!(points[0].is_some());
assert_eq!(points[1], None, "no left neighbour");
assert_eq!(points[2], None, "off the image");
assert_eq!(points[3], None, "0, 1, 2 is straight: no curvature");
}
#[test]
fn ridge_points_reject_mismatched_inputs() {
let magnitude = column_ridge([0.0, 1.0, 2.0, 1.5, 0.0]);
let wrong: Image<MonoF32> = Image::fill(4, 3, MonoF32::new(1.0));
let right: Image<MonoF32> = Image::fill(5, 3, MonoF32::new(0.0));
let sites = [Coordinate::new(2, 1)];
assert!(matches!(
interpolate_ridge_points(sites, &magnitude, &wrong, &right),
Err(Error::SizeMismatch { .. })
));
assert!(matches!(
interpolate_ridge_points(sites, &magnitude, &right, &wrong),
Err(Error::SizeMismatch { .. })
));
}
#[test]
fn a_traced_contour_interpolates_onto_the_grey_boundary() {
use crate::analyze::contours::{Connectivity8, extract_contours};
use crate::border::Clamp;
use crate::image::BinaryImage;
use crate::pixel::Label32;
use crate::transform::{gaussian_blur, gradient_magnitude, scharr_x, scharr_y};
const LO: usize = 10;
const HI: usize = 25;
let inside = |v: usize| (LO..=HI).contains(&v);
let image: Image<MonoF32> = Image::generate(36, 36, |x, y| {
MonoF32::new(if inside(x) && inside(y) { 1.0 } else { 0.0 })
});
let mask: BinaryImage = Image::generate(36, 36, |x, y| inside(x) && inside(y));
let blurred: Image<MonoF32> = gaussian_blur(&image, sigma!(1.0), &Clamp);
let gx = scharr_x(&blurred, &Clamp);
let gy = scharr_y(&blurred, &Clamp);
let magnitude = gradient_magnitude(&gx, &gy).unwrap();
let (_, hierarchy) = extract_contours::<Label32, Connectivity8>(&mask).unwrap();
let contour = hierarchy.components()[0].outer();
let vertices = contour.points();
let points =
interpolate_ridge_points(vertices.iter().copied(), &magnitude, &gx, &gy).unwrap();
assert_eq!(
points.len(),
vertices.len(),
"one entry per vertex, in vertex order",
);
let mid = |v: usize| (LO + 5..=HI - 5).contains(&v);
let mut checked = 0;
for (vertex, point) in vertices.iter().zip(&points) {
let (vx, vy) = (vertex.x as f64, vertex.y as f64);
let expected = match (*vertex, mid(vertex.x), mid(vertex.y)) {
(v, _, true) if v.x == LO => Some((LO as f64 - 0.5, vy)),
(v, _, true) if v.x == HI => Some((HI as f64 + 0.5, vy)),
(v, true, _) if v.y == LO => Some((vx, LO as f64 - 0.5)),
(v, true, _) if v.y == HI => Some((vx, HI as f64 + 0.5)),
_ => None, };
if let Some((ex, ey)) = expected {
let point =
point.unwrap_or_else(|| panic!("mid-side vertex {vertex:?} was refused a fit"));
assert!(
(point.x - ex).abs() < 1e-3 && (point.y - ey).abs() < 1e-3,
"vertex {vertex:?} interpolated to {point:?}, expected ({ex}, {ey})",
);
checked += 1;
}
}
assert_eq!(checked, 4 * (HI - LO - 9), "every mid-side vertex checked");
}
#[test]
fn no_sites_is_no_points() {
let magnitude = column_ridge([0.0, 1.0, 2.0, 1.5, 0.0]);
let flat: Image<MonoF32> = Image::fill(5, 3, MonoF32::new(1.0));
let points = interpolate_ridge_points([], &magnitude, &flat, &flat).unwrap();
assert!(points.is_empty());
}
}