Skip to main content

checkerboard_calibrate/chessboard/
binarize.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Binarization primitives used by the chessboard detector, ported to match
5//! OpenCV's `equalizeHist` and `adaptiveThreshold(ADAPTIVE_THRESH_MEAN_C)`
6//! exactly.
7
8/// `saturate_cast<uchar>(float)` — round to nearest (ties to even, like
9/// `cvRound`) and clamp to `[0, 255]`.
10fn saturate_u8(x: f64) -> u8 {
11    x.round_ties_even().clamp(0.0, 255.0) as u8
12}
13
14/// Port of OpenCV `equalizeHist` for 8-bit single-channel images.
15///
16/// Builds the cumulative-histogram lookup table exactly as OpenCV does: the
17/// first occupied bin maps to 0, and the remainder is scaled by
18/// `255 / (total - hist[first])`.
19pub fn equalize_hist(src: &[u8]) -> Vec<u8> {
20    let mut hist = [0u64; 256];
21    for &v in src {
22        hist[v as usize] += 1;
23    }
24    let total = src.len() as u64;
25
26    // First non-empty bin.
27    let mut first = 0usize;
28    while first < 256 && hist[first] == 0 {
29        first += 1;
30    }
31    if first == 256 {
32        // Empty input.
33        return Vec::new();
34    }
35    if hist[first] == total {
36        // Single intensity: OpenCV fills the output with that intensity.
37        return vec![first as u8; src.len()];
38    }
39
40    let scale = 255.0 / (total - hist[first]) as f64;
41    let mut lut = [0u8; 256];
42    lut[first] = 0;
43    let mut sum = 0u64;
44    for (i, &h) in hist.iter().enumerate().skip(first + 1) {
45        sum += h;
46        lut[i] = saturate_u8(sum as f64 * scale);
47    }
48
49    src.iter().map(|&v| lut[v as usize]).collect()
50}
51
52/// Port of OpenCV `adaptiveThreshold` with `ADAPTIVE_THRESH_MEAN_C` and
53/// `THRESH_BINARY`.
54///
55/// For each pixel the local mean over a `block_size x block_size` neighborhood
56/// is computed with a normalized box filter and `BORDER_REPLICATE` (so the
57/// kernel area is always `block_size^2`). A pixel becomes `255` when
58/// `src > mean - ceil(c)`, else `0`.
59///
60/// Panics if `block_size` is even or less than 3, matching OpenCV's
61/// requirement of an odd window.
62pub fn adaptive_threshold_mean(
63    src: &[u8],
64    width: usize,
65    height: usize,
66    block_size: usize,
67    c: f64,
68) -> Vec<u8> {
69    assert_eq!(src.len(), width * height, "src length must be width*height");
70    assert!(
71        block_size >= 3 && block_size % 2 == 1,
72        "block_size must be odd and >= 3"
73    );
74
75    let r = block_size / 2;
76    let pw = width + 2 * r;
77    let ph = height + 2 * r;
78
79    // Replicate-padded integral image. `integral[(y+1)*(pw+1) + (x+1)]` is the
80    // sum of padded pixels in `[0, x] x [0, y]`.
81    let stride = pw + 1;
82    let mut integral = vec![0i64; stride * (ph + 1)];
83    for py in 0..ph {
84        // Source row with replicate clamping.
85        let sy = py.saturating_sub(r).min(height - 1);
86        let mut row_sum = 0i64;
87        for px in 0..pw {
88            let sx = px.saturating_sub(r).min(width - 1);
89            row_sum += src[sy * width + sx] as i64;
90            integral[(py + 1) * stride + (px + 1)] = integral[py * stride + (px + 1)] + row_sum;
91        }
92    }
93
94    let area = (block_size * block_size) as f64;
95    let idelta = c.ceil() as i64; // THRESH_BINARY uses ceil(delta)
96
97    let mut dst = vec![0u8; src.len()];
98    for y in 0..height {
99        for x in 0..width {
100            // Window in padded coords: rows [y, y+block_size), cols [x, x+block_size).
101            let y0 = y;
102            let y1 = y + block_size;
103            let x0 = x;
104            let x1 = x + block_size;
105            let sum = integral[y1 * stride + x1]
106                - integral[y0 * stride + x1]
107                - integral[y1 * stride + x0]
108                + integral[y0 * stride + x0];
109            let mean = saturate_u8(sum as f64 / area) as i64;
110            let v = src[y * width + x] as i64;
111            dst[y * width + x] = if v > mean - idelta { 255 } else { 0 };
112        }
113    }
114    dst
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn equalize_hist_known_lut() {
123        // Four distinct intensities, one pixel each.
124        // first=0, hist[0]=1, total=4, scale=255/3=85.
125        // lut = [0, 85, 170, 255].
126        let src = [0u8, 1, 2, 3];
127        assert_eq!(equalize_hist(&src), vec![0, 85, 170, 255]);
128    }
129
130    #[test]
131    fn equalize_hist_constant_image() {
132        let src = [42u8; 10];
133        assert_eq!(equalize_hist(&src), vec![42u8; 10]);
134    }
135
136    #[test]
137    fn equalize_hist_spreads_to_full_range() {
138        // A clustered histogram should map min->0 and max->255.
139        let src = [100u8, 100, 100, 150, 200];
140        let out = equalize_hist(&src);
141        assert_eq!(out[0], 0);
142        assert_eq!(*out.last().unwrap(), 255);
143        // Monotonic in intensity.
144        assert!(out[3] <= out[4]);
145    }
146
147    #[test]
148    fn adaptive_threshold_constant_image() {
149        let src = [100u8; 9];
150        // c = 0 -> idelta 0 -> v > mean is false everywhere -> all black.
151        assert_eq!(adaptive_threshold_mean(&src, 3, 3, 3, 0.0), vec![0u8; 9]);
152        // c = 1 -> idelta 1 -> 100 > 99 true -> all white.
153        assert_eq!(adaptive_threshold_mean(&src, 3, 3, 3, 1.0), vec![255u8; 9]);
154    }
155
156    #[test]
157    fn adaptive_threshold_bright_pixel() {
158        // Center pixel much brighter than its neighborhood becomes white; the
159        // dark surround stays black (with c = 0).
160        #[rustfmt::skip]
161        let src = [
162            10, 10, 10,
163            10, 250, 10,
164            10, 10, 10u8,
165        ];
166        let out = adaptive_threshold_mean(&src, 3, 3, 3, 0.0);
167        // mean of the 3x3 replicate-padded window for the center ~ (8*10+250)/9
168        // = 36.7 -> 37; 250 > 37 -> white.
169        assert_eq!(out[4], 255);
170        // A corner pixel's neighborhood is dominated by 10s -> mean ~ small,
171        // 10 > mean is false (10 ~ mean) -> black.
172        assert_eq!(out[0], 0);
173    }
174}