use crate::Error;
use crate::analyze::components::{Connectivity8, connected_components};
use crate::image::{BinaryImage, ImageView, RasterImage, RasterImageMut};
use crate::pixel::{Label32, LabelPixel, SingleChannel};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct HysteresisThresholds<C> {
low: C,
high: C,
}
impl<C> HysteresisThresholds<C>
where
C: PartialOrd + Copy + core::fmt::Debug,
{
pub fn try_new(low: C, high: C) -> Result<Self, Error> {
if low <= high {
Ok(Self { low, high })
} else {
Err(Error::InvalidParameter(format!(
"hysteresis thresholds must satisfy low <= high, got low {low:?} and high {high:?}"
)))
}
}
}
impl<C: Copy> HysteresisThresholds<C> {
#[must_use]
pub const fn low(self) -> C {
self.low
}
#[must_use]
pub const fn high(self) -> C {
self.high
}
pub(crate) fn map_monotone<D: Copy>(self, convert: impl Fn(C) -> D) -> HysteresisThresholds<D> {
HysteresisThresholds {
low: convert(self.low),
high: convert(self.high),
}
}
}
pub fn hysteresis_threshold<I, P>(
image: &I,
thresholds: HysteresisThresholds<P::Channel>,
) -> BinaryImage
where
I: RasterImage<Pixel = P>,
P: SingleChannel,
P::Channel: PartialOrd + Copy + core::fmt::Debug,
{
let mut out = BinaryImage::fill(image.width(), image.height(), false);
hysteresis_threshold_into(image, thresholds, &mut out);
out
}
pub fn hysteresis_threshold_into<I, P>(
image: &I,
thresholds: HysteresisThresholds<P::Channel>,
out: &mut BinaryImage,
) where
I: RasterImage<Pixel = P>,
P: SingleChannel,
P::Channel: PartialOrd + Copy + core::fmt::Debug,
{
assert_eq!(
out.size(),
image.size(),
"hysteresis_threshold_into: output size {:?} does not match input {:?}",
out.size(),
image.size()
);
let (low, high) = (thresholds.low(), thresholds.high());
let w = image.width();
let h = image.height();
let mut weak = BinaryImage::fill(w, h, false);
for y in 0..h {
let src = image.row(y);
let dst = weak.row_mut(y);
for (out_px, src_px) in dst.iter_mut().zip(src) {
*out_px = src_px.channel(0) >= low;
}
}
let labeling = connected_components::<Label32, Connectivity8>(&weak).expect(
"hysteresis_threshold: weak-component count exceeds Label32 capacity (u32::MAX); \
an image with that many components is not representable",
);
let mut keep = vec![false; labeling.label_count as usize + 1];
for y in 0..h {
let weak_row = weak.row(y);
let img_row = image.row(y);
let label_row = labeling.labels.row(y);
for x in 0..w {
if weak_row[x] && img_row[x].channel(0) >= high {
keep[label_row[x].to_label_index() as usize] = true;
}
}
}
for y in 0..h {
let weak_row = weak.row(y);
let label_row = labeling.labels.row(y);
let out_row = out.row_mut(y);
for x in 0..w {
out_row[x] = weak_row[x] && keep[label_row[x].to_label_index() as usize];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::{Image, ImageView};
use crate::pixel::{Mono8, MonoF32};
use std::num::Saturating;
fn mask(rows: &[&str]) -> Image<Mono8> {
let h = rows.len();
let w = rows[0].chars().count();
for r in rows {
assert_eq!(r.chars().count(), w, "ragged fixture row: {r:?}");
}
Image::generate(w, h, |x, y| {
let c = rows[y].chars().nth(x).unwrap();
let v = match c {
'#' => 255u8,
'+' => 128,
'.' => 0,
other => panic!("unexpected fixture char {other:?}"),
};
Mono8::new(v)
})
}
const LOW: Saturating<u8> = Saturating(100);
const HIGH: Saturating<u8> = Saturating(200);
fn pair() -> HysteresisThresholds<Saturating<u8>> {
HysteresisThresholds::try_new(LOW, HIGH).unwrap()
}
fn set_true(m: &BinaryImage) -> std::collections::BTreeSet<(usize, usize)> {
let mut s = std::collections::BTreeSet::new();
for y in 0..m.height() {
for x in 0..m.width() {
if m.pixel_at(x, y) {
s.insert((x, y));
}
}
}
s
}
#[test]
fn all_below_low_is_empty() {
let img = mask(&["...", "..."]);
let out = hysteresis_threshold(&img, pair());
assert!(set_true(&out).is_empty());
}
#[test]
fn all_above_high_is_full() {
let img = mask(&["###", "###"]);
let out = hysteresis_threshold(&img, pair());
for y in 0..out.height() {
for x in 0..out.width() {
assert!(out.pixel_at(x, y), "({x},{y}) should be kept");
}
}
}
#[test]
fn isolated_weak_is_dropped() {
let img = mask(&["....", ".++.", ".++.", "...."]);
let out = hysteresis_threshold(&img, pair());
assert!(set_true(&out).is_empty());
}
#[test]
fn weak_bridges_to_strong_kept() {
let img = mask(&["....", ".#++", "...."]);
let out = hysteresis_threshold(&img, pair());
let expected: std::collections::BTreeSet<_> =
[(1, 1), (2, 1), (3, 1)].into_iter().collect();
assert_eq!(set_true(&out), expected);
}
#[test]
fn diagonal_propagation_uses_8_connectivity() {
let img = mask(&["#..", ".+.", "..+"]);
let out = hysteresis_threshold(&img, pair());
let expected: std::collections::BTreeSet<_> =
[(0, 0), (1, 1), (2, 2)].into_iter().collect();
assert_eq!(set_true(&out), expected);
}
#[test]
fn two_separate_components_independent() {
let img = mask(&["##...", "##...", ".....", "...++", "...++"]);
let out = hysteresis_threshold(&img, pair());
let expected: std::collections::BTreeSet<_> =
[(0, 0), (1, 0), (0, 1), (1, 1)].into_iter().collect();
assert_eq!(set_true(&out), expected);
}
#[test]
fn boundary_inclusive_at_low_and_high() {
let img = Image::from_vec(
3,
1,
vec![Mono8::new(127), Mono8::new(128), Mono8::new(200)],
)
.unwrap();
let t = HysteresisThresholds::try_new(Saturating(128), Saturating(200)).unwrap();
let out = hysteresis_threshold(&img, t);
assert!(!out.pixel_at(0, 0), "127 < low → non-edge");
assert!(out.pixel_at(1, 0), "128 == low → weak, bridged to strong");
assert!(out.pixel_at(2, 0), "200 == high → strong");
}
#[test]
fn float_magnitude_input() {
let img = Image::from_vec(
3,
1,
vec![MonoF32::new(0.1), MonoF32::new(0.3), MonoF32::new(0.6)],
)
.unwrap();
let t = HysteresisThresholds::try_new(0.2f32, 0.5f32).unwrap();
let out = hysteresis_threshold(&img, t);
assert!(!out.pixel_at(0, 0), "0.1 < low → non-edge");
assert!(out.pixel_at(1, 0), "0.3 weak, bridged to the strong 0.6");
assert!(out.pixel_at(2, 0), "0.6 >= high → strong");
}
#[test]
fn low_equals_high_keeps_only_strong() {
let img = Image::from_vec(
4,
1,
vec![
Mono8::new(0),
Mono8::new(199),
Mono8::new(200),
Mono8::new(255),
],
)
.unwrap();
let t = HysteresisThresholds::try_new(Saturating(200), Saturating(200)).unwrap();
let out = hysteresis_threshold(&img, t);
assert!(!out.pixel_at(0, 0), "0 < high");
assert!(!out.pixel_at(1, 0), "199 < high, and there is no weak band");
assert!(out.pixel_at(2, 0), "200 == high → strong");
assert!(out.pixel_at(3, 0), "255 >= high → strong");
}
#[test]
fn into_variant_matches_owned() {
let img = mask(&["#++.", ".+..", "..+#", "++.."]);
let owned = hysteresis_threshold(&img, pair());
let mut into = BinaryImage::fill(img.width(), img.height(), true);
hysteresis_threshold_into(&img, pair(), &mut into);
assert_eq!(set_true(&owned), set_true(&into));
}
#[test]
#[should_panic(expected = "does not match input")]
fn into_wrong_size_panics() {
let img = mask(&["##", "##"]);
let mut out = BinaryImage::fill(3, 3, false);
hysteresis_threshold_into(&img, pair(), &mut out);
}
#[test]
fn misordered_pair_is_rejected_at_construction() {
assert!(HysteresisThresholds::try_new(HIGH, LOW).is_err());
}
#[test]
fn computed_pair_reports_misordering_as_a_value() {
let err = HysteresisThresholds::try_new(0.5_f32, 0.2).unwrap_err();
assert!(
matches!(err, Error::InvalidParameter(ref m) if m.contains("low <= high")),
"unexpected error: {err:?}"
);
}
#[test]
fn nan_threshold_is_rejected() {
assert!(HysteresisThresholds::try_new(f32::NAN, 0.5).is_err());
assert!(HysteresisThresholds::try_new(0.2_f32, f32::NAN).is_err());
assert!(HysteresisThresholds::try_new(f32::NAN, f32::NAN).is_err());
}
#[test]
fn equal_thresholds_are_valid() {
let t = HysteresisThresholds::try_new(LOW, LOW).unwrap();
assert_eq!(t.low(), t.high());
}
#[test]
fn accessors_return_what_was_given() {
let t = pair();
assert_eq!(t.low(), LOW);
assert_eq!(t.high(), HIGH);
}
#[test]
fn map_monotone_re_types_without_re_validating() {
let t = HysteresisThresholds::try_new(0.1_f32, 0.3).unwrap();
let widened: HysteresisThresholds<f64> = t.map_monotone(f64::from);
assert_eq!(widened.low(), 0.1_f32 as f64);
assert_eq!(widened.high(), 0.3_f32 as f64);
}
}