use crate::analyze::components::{Connectivity8, connected_components};
use crate::image::{BinaryImage, ImageView, RasterImage, RasterImageMut};
use crate::pixel::{Label32, LabelPixel, SingleChannel};
pub fn hysteresis_threshold<I, P>(image: &I, low: P::Channel, high: 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, low, high, &mut out);
out
}
pub fn hysteresis_threshold_into<I, P>(
image: &I,
low: P::Channel,
high: 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()
);
assert!(
low <= high,
"hysteresis_threshold: low ({low:?}) must be <= high ({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 + 1) as usize];
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 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, LOW, HIGH);
assert!(set_true(&out).is_empty());
}
#[test]
fn all_above_high_is_full() {
let img = mask(&["###", "###"]);
let out = hysteresis_threshold(&img, LOW, HIGH);
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, LOW, HIGH);
assert!(set_true(&out).is_empty());
}
#[test]
fn weak_bridges_to_strong_kept() {
let img = mask(&["....", ".#++", "...."]);
let out = hysteresis_threshold(&img, LOW, HIGH);
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, LOW, HIGH);
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, LOW, HIGH);
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 out = hysteresis_threshold(&img, Saturating(128), Saturating(200));
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 out = hysteresis_threshold(&img, 0.2f32, 0.5f32);
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 out = hysteresis_threshold(&img, Saturating(200), Saturating(200));
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, LOW, HIGH);
let mut into = BinaryImage::fill(img.width(), img.height(), true);
hysteresis_threshold_into(&img, LOW, HIGH, &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, LOW, HIGH, &mut out);
}
#[test]
#[should_panic(expected = "must be <= high")]
fn low_greater_than_high_panics() {
let img = mask(&["#+.", "+.#"]);
let _ = hysteresis_threshold(&img, HIGH, LOW); }
}