use crate::Error;
use crate::analyze::histogram::strategy::BinningStrategy;
use crate::analyze::histogram::{Histogram, NaturalBins, histogram};
use crate::image::{BinaryImage, RasterImage};
use crate::pixel::HomogeneousPixel;
use crate::transform::{BinaryMask, convert_image};
pub fn otsu_threshold<V: Copy>(hist: &Histogram<NaturalBins, V>) -> Option<u8>
where
NaturalBins: BinningStrategy<V>,
{
let bins = hist.bins();
debug_assert_eq!(
bins.len(),
256,
"otsu_threshold: NaturalBins must yield exactly 256 bins"
);
let total: u64 = bins.iter().sum();
if total == 0 {
return None;
}
let sum_total: u64 = bins.iter().enumerate().map(|(i, &c)| (i as u64) * c).sum();
let mut w0: u64 = 0;
let mut sum_b: u64 = 0;
let mut best_var: f64 = -1.0;
let mut best_t: Option<u8> = None;
for (t, &c) in bins.iter().enumerate() {
w0 += c;
if w0 == 0 {
continue;
}
let w1 = total - w0;
if w1 == 0 {
break;
}
sum_b += (t as u64) * c;
let mu0 = sum_b as f64 / w0 as f64;
let mu1 = (sum_total - sum_b) as f64 / w1 as f64;
let diff = mu0 - mu1;
let var_between = (w0 as f64) * (w1 as f64) * diff * diff;
if var_between > best_var {
best_var = var_between;
best_t = Some(t as u8);
}
}
best_t
}
pub fn otsu_binary_mask<I, P>(image: &I) -> Result<(u8, BinaryImage), Error>
where
I: RasterImage<Pixel = P>,
P: HomogeneousPixel + From<u8>,
P::Channel: Ord,
NaturalBins: BinningStrategy<P::Channel>,
{
assert_eq!(
P::CHANNEL_COUNT,
1,
"otsu_binary_mask: requires a single-channel pixel; got CHANNEL_COUNT = {}",
P::CHANNEL_COUNT
);
let h: Histogram<NaturalBins, P::Channel> = histogram(image, &NaturalBins)?;
let t = otsu_threshold(&h).unwrap_or(0);
let thresh = P::from(t);
let mask: BinaryImage = convert_image(image, BinaryMask { thresh });
Ok((t, mask))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::{Image, ImageView};
use crate::pixel::{Indexed8, Mono8};
use std::num::Saturating;
fn hist_from_bins_u8(bins: Vec<u64>) -> Histogram<NaturalBins, u8> {
assert_eq!(bins.len(), 256);
Histogram::new(NaturalBins, bins, 0, 0, 0)
}
#[test]
fn otsu_threshold_bimodal_picks_valley() {
let mut bins = vec![0u64; 256];
for (i, bin) in bins.iter_mut().enumerate() {
let d0 = (i as i32 - 64) as f64;
let d1 = (i as i32 - 192) as f64;
let g = (-d0 * d0 / (2.0 * 20.0 * 20.0)).exp() + (-d1 * d1 / (2.0 * 20.0 * 20.0)).exp();
*bin = (g * 1000.0) as u64;
}
let h = hist_from_bins_u8(bins);
let t = otsu_threshold(&h).expect("bimodal histogram must yield a threshold");
assert!(
(118..=138).contains(&(t as u32)),
"expected ~128 between the modes; got {t}",
);
}
#[test]
fn otsu_threshold_bimodal_non_overlapping_modes_pick_between() {
let mut bins = vec![0u64; 256];
for (i, bin) in bins.iter_mut().enumerate() {
let d0 = (i as i32 - 64) as f64;
let d1 = (i as i32 - 192) as f64;
let g = (-d0 * d0 / (2.0 * 10.0 * 10.0)).exp() + (-d1 * d1 / (2.0 * 10.0 * 10.0)).exp();
*bin = (g * 1000.0) as u64;
}
let h = hist_from_bins_u8(bins);
let t = otsu_threshold(&h).expect("bimodal histogram must yield a threshold");
assert!(
(64..192).contains(&(t as u32)),
"expected t strictly between the two modes; got {t}",
);
}
#[test]
fn otsu_threshold_uniform_returns_central_value() {
let bins = vec![1u64; 256];
let h = hist_from_bins_u8(bins);
let t = otsu_threshold(&h).expect("uniform histogram has a meaningful split");
assert!(
(50..=200).contains(&(t as u32)),
"uniform histogram should pick a central threshold; got {t}",
);
}
#[test]
fn otsu_threshold_all_in_one_bin_returns_none() {
let mut bins = vec![0u64; 256];
bins[100] = 50;
let h = hist_from_bins_u8(bins);
assert_eq!(otsu_threshold(&h), None);
}
#[test]
fn otsu_threshold_all_in_first_bin_returns_none() {
let mut bins = vec![0u64; 256];
bins[0] = 42;
let h = hist_from_bins_u8(bins);
assert_eq!(otsu_threshold(&h), None);
}
#[test]
fn otsu_threshold_all_in_last_bin_returns_none() {
let mut bins = vec![0u64; 256];
bins[255] = 7;
let h = hist_from_bins_u8(bins);
assert_eq!(otsu_threshold(&h), None);
}
#[test]
fn otsu_threshold_empty_total_returns_none() {
let bins = vec![0u64; 256];
let h = hist_from_bins_u8(bins);
assert_eq!(otsu_threshold(&h), None);
}
#[test]
fn otsu_threshold_two_pixels_split() {
let mut bins = vec![0u64; 256];
bins[10] = 1;
bins[200] = 1;
let h = hist_from_bins_u8(bins);
let t = otsu_threshold(&h).expect("two distinct pixels must yield a split");
assert!(
(10..200).contains(&(t as u32)),
"expected t in [10, 200); got {t}",
);
}
#[test]
fn otsu_threshold_matches_known_reference() {
let mut bins = vec![0u64; 256];
bins[50] = 5;
bins[150] = 5;
let h = hist_from_bins_u8(bins);
assert_eq!(otsu_threshold(&h), Some(50));
}
#[test]
fn otsu_threshold_unequal_classes() {
let mut bins = vec![0u64; 256];
bins[40] = 100;
bins[210] = 5;
let h = hist_from_bins_u8(bins);
let t = otsu_threshold(&h).expect("bimodal split exists");
assert!((40..210).contains(&(t as u32)));
}
#[test]
fn otsu_threshold_accepts_saturating_u8_channel() {
let mut bins = vec![0u64; 256];
bins[10] = 1;
bins[240] = 1;
let h: Histogram<NaturalBins, Saturating<u8>> = Histogram::new(NaturalBins, bins, 0, 0, 0);
let t = otsu_threshold(&h).unwrap();
assert!((10..240).contains(&(t as u32)));
}
#[test]
fn otsu_threshold_ignores_outlier_counters() {
let mut bins = vec![0u64; 256];
bins[50] = 5;
bins[150] = 5;
let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, bins, 999, 999, 999);
assert_eq!(otsu_threshold(&h), Some(50));
}
#[test]
fn otsu_binary_mask_mono8_separates_black_and_white() {
let img = Image::from_vec(
2,
2,
vec![
Mono8::new(0),
Mono8::new(0),
Mono8::new(255),
Mono8::new(255),
],
)
.unwrap();
let (t, mask) = otsu_binary_mask(&img).unwrap();
assert!(
t < 255,
"threshold must leave the bright class non-empty; got {t}",
);
assert!(!mask.pixel_at(0, 0));
assert!(!mask.pixel_at(1, 0));
assert!(mask.pixel_at(0, 1));
assert!(mask.pixel_at(1, 1));
}
#[test]
fn otsu_binary_mask_indexed8_runs_on_u8_channel() {
let img = Image::from_vec(
2,
2,
vec![Indexed8(10), Indexed8(10), Indexed8(200), Indexed8(200)],
)
.unwrap();
let (t, mask) = otsu_binary_mask(&img).unwrap();
assert!((10..200).contains(&(t as u32)));
assert!(!mask.pixel_at(0, 0));
assert!(mask.pixel_at(0, 1));
}
#[test]
fn otsu_binary_mask_flat_image_falls_back_to_zero_threshold() {
let img: Image<Mono8> = Image::fill(3, 3, Mono8::new(42));
let (t, mask) = otsu_binary_mask(&img).unwrap();
assert_eq!(t, 0);
for y in 0..mask.height() {
for x in 0..mask.width() {
assert!(mask.pixel_at(x, y));
}
}
}
#[test]
fn otsu_binary_mask_preserves_image_size() {
let img: Image<Mono8> =
Image::generate(7, 5, |x, y| Mono8::new(((x * 30 + y * 50) % 256) as u8));
let (_, mask) = otsu_binary_mask(&img).unwrap();
assert_eq!(mask.size(), img.size());
}
#[test]
fn otsu_binary_mask_threshold_matches_standalone_otsu() {
let img: Image<Mono8> = Image::generate(16, 16, |x, y| {
Mono8::new(if (x + y) % 2 == 0 { 30 } else { 200 })
});
let h: Histogram<NaturalBins, _> = histogram(&img, &NaturalBins).unwrap();
let direct = otsu_threshold(&h).unwrap();
let (via_mask, _) = otsu_binary_mask(&img).unwrap();
assert_eq!(via_mask, direct);
}
}