use std::ops::Sub;
use crate::Error;
use crate::analyze::integral::{IntegralImage, integral_image};
use crate::image::{BinaryImage, ImageView, RasterImage, RasterImageMut};
use crate::pixel::{IntegralPixel, Mono32, Mono64, MonoF64};
use crate::{Coordinate, Rectangle, Size};
mod sealed {
pub trait Sealed {}
impl Sealed for crate::pixel::Mono32 {}
impl Sealed for crate::pixel::Mono64 {}
impl Sealed for crate::pixel::MonoF64 {}
}
pub trait AdaptiveAccumulator: sealed::Sealed + Copy + Sub<Output = Self> {
type Offset: Copy + core::fmt::Debug + PartialEq;
#[doc(hidden)]
fn integral_of<I>(image: &I) -> Result<IntegralImage<Self>, Error>
where
I: RasterImage,
I::Pixel: IntegralPixel<Self>;
#[doc(hidden)]
fn exceeds_local_mean(pixel: Self, sum: Self, area: u64, offset: Self::Offset) -> bool;
}
impl AdaptiveAccumulator for Mono32 {
type Offset = i64;
#[inline]
fn integral_of<I>(image: &I) -> Result<IntegralImage<Self>, Error>
where
I: RasterImage,
I::Pixel: IntegralPixel<Self>,
{
integral_image::<I, Self>(image)
}
#[inline]
fn exceeds_local_mean(pixel: Self, sum: Self, area: u64, offset: i64) -> bool {
let p = pixel.value() as i128;
let s = sum.value() as i128;
(p + offset as i128) * (area as i128) > s
}
}
impl AdaptiveAccumulator for Mono64 {
type Offset = i64;
#[inline]
fn integral_of<I>(image: &I) -> Result<IntegralImage<Self>, Error>
where
I: RasterImage,
I::Pixel: IntegralPixel<Self>,
{
integral_image::<I, Self>(image)
}
#[inline]
fn exceeds_local_mean(pixel: Self, sum: Self, area: u64, offset: i64) -> bool {
let p = pixel.value() as i128;
let s = sum.value() as i128;
(p + offset as i128) * (area as i128) > s
}
}
impl AdaptiveAccumulator for MonoF64 {
type Offset = f64;
#[inline]
fn integral_of<I>(image: &I) -> Result<IntegralImage<Self>, Error>
where
I: RasterImage,
I::Pixel: IntegralPixel<Self>,
{
integral_image::<I, Self>(image)
}
#[inline]
fn exceeds_local_mean(pixel: Self, sum: Self, area: u64, offset: f64) -> bool {
let p = pixel.value();
let s = sum.value();
(p + offset) * (area as f64) > s
}
}
pub struct Bias<A: AdaptiveAccumulator>(A::Offset);
impl<A: AdaptiveAccumulator> Bias<A> {
#[inline]
pub fn new(offset: A::Offset) -> Self {
Bias(offset)
}
#[inline]
pub fn get(self) -> A::Offset {
self.0
}
}
impl<A: AdaptiveAccumulator> Clone for Bias<A> {
fn clone(&self) -> Self {
*self
}
}
impl<A: AdaptiveAccumulator> Copy for Bias<A> {}
impl<A: AdaptiveAccumulator> core::fmt::Debug for Bias<A> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_tuple("Bias").field(&self.0).finish()
}
}
impl<A: AdaptiveAccumulator> PartialEq for Bias<A> {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
pub fn adaptive_threshold<I, A>(
image: &I,
window: usize,
offset: Bias<A>,
) -> Result<BinaryImage, Error>
where
I: RasterImage,
I::Pixel: IntegralPixel<A>,
A: AdaptiveAccumulator,
{
let mut out = BinaryImage::fill(image.width(), image.height(), false);
adaptive_threshold_into(image, window, offset, &mut out)?;
Ok(out)
}
pub fn adaptive_threshold_into<I, A>(
image: &I,
window: usize,
offset: Bias<A>,
out: &mut BinaryImage,
) -> Result<(), Error>
where
I: RasterImage,
I::Pixel: IntegralPixel<A>,
A: AdaptiveAccumulator,
{
assert!(
window != 0 && window % 2 == 1,
"adaptive_threshold: window must be odd and non-zero, got {window}"
);
assert_eq!(
out.size(),
image.size(),
"adaptive_threshold_into: output size {:?} does not match input {:?}",
out.size(),
image.size()
);
let w = image.width();
let h = image.height();
let sat = A::integral_of(image)?;
let half = window / 2;
let off = offset.0;
for y in 0..h {
let src_row = image.row(y);
let out_row = out.row_mut(y);
let top = y.saturating_sub(half);
let bottom = (y + half + 1).min(h);
for x in 0..w {
let left = x.saturating_sub(half);
let right = (x + half + 1).min(w);
let rect = Rectangle::new(
Coordinate::new(left, top),
Size::new(right - left, bottom - top),
);
let sum = sat.region_sum(rect);
let area = ((right - left) * (bottom - top)) as u64;
let pixel = src_row[x].to_integral();
out_row[x] = A::exceeds_local_mean(pixel, sum, area, off);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::{Image, ImageView, ImageViewMut};
use crate::pixel::{Mono8, MonoF32};
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
}
fn naive(img: &Image<Mono8>, window: usize, offset: i64) -> BinaryImage {
let w = img.width();
let h = img.height();
let half = window / 2;
Image::generate(w, h, |x, y| {
let left = x.saturating_sub(half);
let top = y.saturating_sub(half);
let right = (x + half + 1).min(w);
let bottom = (y + half + 1).min(h);
let mut sum = 0i128;
let mut area = 0i128;
for yy in top..bottom {
for xx in left..right {
sum += img.pixel_at(xx, yy).value() as i128;
area += 1;
}
}
let p = img.pixel_at(x, y).value() as i128;
(p + offset as i128) * area > sum
})
}
#[test]
fn uniform_image_with_zero_offset_all_background() {
let img = Image::fill(5, 5, Mono8::new(100));
let out = adaptive_threshold::<_, Mono32>(&img, 3, Bias::new(0)).unwrap();
assert!(set_true(&out).is_empty());
}
#[test]
fn uniform_image_positive_offset_all_foreground() {
let img = Image::fill(5, 5, Mono8::new(100));
let out = adaptive_threshold::<_, Mono32>(&img, 3, Bias::new(1)).unwrap();
for y in 0..out.height() {
for x in 0..out.width() {
assert!(out.pixel_at(x, y), "({x},{y}) should be foreground");
}
}
}
#[test]
fn step_illumination_gradient() {
let mut img = Image::generate(8, 3, |x, _| Mono8::new(if x < 4 { 50 } else { 150 }));
*img.pixel_at_mut(1, 1) = Mono8::new(90);
let out = adaptive_threshold::<_, Mono32>(&img, 3, Bias::new(0)).unwrap();
assert!(out.pixel_at(1, 1), "local spot must be foreground");
assert!(
!out.pixel_at(6, 1),
"flat bright interior must be background"
);
}
#[test]
fn single_pixel_window_equals_self_threshold() {
let img = Image::generate(4, 4, |x, y| Mono8::new((x * 16 + y * 4) as u8));
let bg = adaptive_threshold::<_, Mono32>(&img, 1, Bias::new(0)).unwrap();
assert!(set_true(&bg).is_empty());
let fg = adaptive_threshold::<_, Mono32>(&img, 1, Bias::new(1)).unwrap();
assert_eq!(set_true(&fg).len(), 16);
}
#[test]
fn window_larger_than_image_clamps() {
let img = Image::generate(3, 3, |x, y| Mono8::new((y * 3 + x + 1) as u8));
let out = adaptive_threshold::<_, Mono32>(&img, 9, Bias::new(0)).unwrap();
let expected: std::collections::BTreeSet<_> = (0..3)
.flat_map(|y| (0..3).map(move |x| (x, y)))
.filter(|&(x, y)| (y * 3 + x + 1) > 5)
.collect();
assert_eq!(set_true(&out), expected);
}
#[test]
fn border_pixels_use_clipped_window() {
let mut img = Image::fill(4, 4, Mono8::new(0));
*img.pixel_at_mut(0, 0) = Mono8::new(10);
*img.pixel_at_mut(1, 0) = Mono8::new(20);
*img.pixel_at_mut(0, 1) = Mono8::new(20);
*img.pixel_at_mut(1, 1) = Mono8::new(10);
let out = adaptive_threshold::<_, Mono32>(&img, 3, Bias::new(0)).unwrap();
assert!(
!out.pixel_at(0, 0),
"corner must divide by the clipped area (4), not the full 9"
);
}
#[test]
#[should_panic(expected = "window must be odd")]
fn even_window_panics() {
let img = Image::fill(4, 4, Mono8::new(0));
let _ = adaptive_threshold::<_, Mono32>(&img, 2, Bias::new(0));
}
#[test]
#[should_panic(expected = "window must be odd")]
fn zero_window_panics() {
let img = Image::fill(4, 4, Mono8::new(0));
let _ = adaptive_threshold::<_, Mono32>(&img, 0, Bias::new(0));
}
#[test]
fn accumulator_overflow_is_err() {
let img = Image::<Mono8>::zero(5000, 5000);
let err = adaptive_threshold::<_, Mono32>(&img, 3, Bias::new(0)).unwrap_err();
assert!(
matches!(err, Error::AccumulatorOverflow { .. }),
"expected AccumulatorOverflow, got {err:?}"
);
}
#[test]
fn into_matches_owned() {
let img = Image::generate(6, 5, |x, y| {
Mono8::new(((x.wrapping_mul(53).wrapping_add(y.wrapping_mul(97))) & 0xFF) as u8)
});
let owned = adaptive_threshold::<_, Mono32>(&img, 3, Bias::new(-3)).unwrap();
let mut into = BinaryImage::fill(img.width(), img.height(), true);
adaptive_threshold_into::<_, Mono32>(&img, 3, Bias::new(-3), &mut into).unwrap();
assert_eq!(set_true(&owned), set_true(&into));
}
#[test]
#[should_panic(expected = "does not match input")]
fn into_wrong_size_panics() {
let img = Image::fill(4, 4, Mono8::new(1));
let mut out = BinaryImage::fill(5, 5, false);
let _ = adaptive_threshold_into::<_, Mono32>(&img, 3, Bias::new(0), &mut out);
}
#[test]
fn matches_naive_local_mean() {
let img = Image::generate(7, 6, |x, y| {
Mono8::new(((x.wrapping_mul(37).wrapping_add(y.wrapping_mul(91))) & 0xFF) as u8)
});
for &window in &[1usize, 3, 5] {
for &offset in &[0i64, 5, -7] {
let got = adaptive_threshold::<_, Mono32>(&img, window, Bias::new(offset)).unwrap();
let want = naive(&img, window, offset);
assert_eq!(
set_true(&got),
set_true(&want),
"mismatch at window={window}, offset={offset}"
);
}
}
}
#[test]
fn float_input_monof64_accumulator() {
let img = Image::generate(5, 4, |x, y| MonoF32::new(((x * 4 + y) as f32) / 32.0));
let w = img.width();
let h = img.height();
let half = 1usize; let want = Image::generate(w, h, |x, y| {
let left = x.saturating_sub(half);
let top = y.saturating_sub(half);
let right = (x + half + 1).min(w);
let bottom = (y + half + 1).min(h);
let mut sum = 0.0f64;
let mut area = 0.0f64;
for yy in top..bottom {
for xx in left..right {
sum += img.pixel_at(xx, yy).value() as f64;
area += 1.0;
}
}
let p = img.pixel_at(x, y).value() as f64;
(p + 0.0) * area > sum
});
let got = adaptive_threshold::<_, MonoF64>(&img, 3, Bias::new(0.0)).unwrap();
assert_eq!(set_true(&got), set_true(&want));
}
#[test]
fn bias_is_debug_and_eq() {
let a = Bias::<Mono32>::new(-5);
let b = Bias::<Mono32>::new(-5);
assert_eq!(a, b);
assert_eq!(a.get(), -5);
assert_eq!(format!("{a:?}"), "Bias(-5)");
}
}