Skip to main content

chess_corners_core/detect/radon/
primitives.rs

1//! Shared primitives for the Duda-Frese (2018) localized Radon
2//! response, used by the whole-image
3//! [`radon_response_u8`](super::radon_response_u8) detection path.
4//!
5//! The module exists so the angular basis, the peak-fit, and the
6//! response-map box blur live in exactly one place.
7
8use serde::{Deserialize, Serialize};
9
10#[cfg(feature = "rayon")]
11use rayon::prelude::*;
12
13/// Subpixel peak-fitting mode.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum PeakFitMode {
18    /// Classic parabolic fit on the raw response values.
19    Parabolic,
20    /// Parabolic fit on `log(response)` — equivalent to fitting a
21    /// Gaussian through three samples. This is the paper default and
22    /// the facade preset.
23    #[default]
24    Gaussian,
25}
26
27/// Fit the peak of three samples along one axis. Returns a fractional
28/// offset in `[-0.5, 0.5]` grid-steps from the middle sample.
29///
30/// The parabolic mode is `y = a + b·x + c·x²`; the Gaussian mode is the
31/// same fit applied to `log(y)`, provided all three samples are
32/// strictly positive. Negative or zero samples trigger a parabolic
33/// fallback. A denominator near zero (flat or rising slope at the
34/// "peak") returns 0.0 rather than diverging.
35#[inline]
36pub(crate) fn fit_peak_frac(y_minus: f32, y_c: f32, y_plus: f32, mode: PeakFitMode) -> f32 {
37    let (ym, y0, yp) = match mode {
38        PeakFitMode::Gaussian if y_minus > 0.0 && y_c > 0.0 && y_plus > 0.0 => {
39            (y_minus.ln(), y_c.ln(), y_plus.ln())
40        }
41        _ => (y_minus, y_c, y_plus),
42    };
43    let denom = ym - 2.0 * y0 + yp;
44    // A true maximum has denom < 0. If denom ≥ 0 the neighbours aren't
45    // strictly below the centre; fall back to "no subpixel shift"
46    // rather than producing a divergent extrapolation.
47    if denom > -1e-12 {
48        return 0.0;
49    }
50    let frac = 0.5 * (ym - yp) / denom;
51    frac.clamp(-0.5, 0.5)
52}
53
54/// Separable `(2·radius+1)²` box blur applied in place to a flat
55/// row-major `w × h` grid. `scratch` must match `resp` in length and
56/// is used as temporary storage. `radius = 0` is a no-op.
57///
58/// The grid does not need to be square: `w` and `h` are taken
59/// independently so whole-image response maps (typically rectangular
60/// at camera resolution) and the refiner's square local response
61/// patch share the same implementation.
62pub(crate) fn box_blur_inplace(
63    resp: &mut [f32],
64    scratch: &mut [f32],
65    w: usize,
66    h: usize,
67    radius: usize,
68) {
69    debug_assert_eq!(resp.len(), w * h);
70    debug_assert_eq!(scratch.len(), w * h);
71    if radius == 0 {
72        return;
73    }
74    // `par_chunks_mut(w)` / `chunks_mut(w)` panic when `w == 0`; the
75    // original loop-based implementation returned a silent no-op on
76    // zero-extent grids, so preserve that contract here too.
77    if w == 0 || h == 0 {
78        return;
79    }
80
81    // Horizontal pass: resp[y, x] -> scratch[y, x].
82    //
83    // Each row is independent, so this trivially parallelizes
84    // row-wise under the `rayon` feature.
85    let horiz_kernel = |y: usize, scratch_row: &mut [f32], resp_full: &[f32]| {
86        let row_start = y * w;
87        for (x, dst) in scratch_row.iter_mut().enumerate() {
88            let x0 = x.saturating_sub(radius);
89            let x1 = (x + radius + 1).min(w);
90            let mut acc = 0.0f32;
91            let mut n = 0.0f32;
92            for xx in x0..x1 {
93                acc += resp_full[row_start + xx];
94                n += 1.0;
95            }
96            *dst = acc / n;
97        }
98    };
99
100    #[cfg(feature = "rayon")]
101    {
102        // Need to read from `resp` while writing to `scratch`, so
103        // borrow `resp` immutably for the read view first.
104        let resp_view: &[f32] = resp;
105        scratch
106            .par_chunks_mut(w)
107            .enumerate()
108            .for_each(|(y, scratch_row)| {
109                horiz_kernel(y, scratch_row, resp_view);
110            });
111    }
112    #[cfg(not(feature = "rayon"))]
113    {
114        let resp_view: &[f32] = resp;
115        for (y, scratch_row) in scratch.chunks_mut(w).enumerate() {
116            horiz_kernel(y, scratch_row, resp_view);
117        }
118    }
119
120    // Vertical pass: scratch[y, x] -> resp[y, x].
121    //
122    // Rewritten to row-major: for each output row `y`, accumulate the
123    // contributions from scratch rows `y0..y1` into the output. This
124    // gives unit-stride reads/writes (vs. the earlier column-strided
125    // `for x { for y { ... } }`), and lets each output row be filled
126    // independently of every other output row.
127    let vert_kernel = |y: usize, dst: &mut [f32], scratch_full: &[f32]| {
128        let y0 = y.saturating_sub(radius);
129        let y1 = (y + radius + 1).min(h);
130        let n = (y1 - y0) as f32;
131        for v in dst.iter_mut() {
132            *v = 0.0;
133        }
134        for yy in y0..y1 {
135            let src_row = &scratch_full[yy * w..(yy + 1) * w];
136            for (d, s) in dst.iter_mut().zip(src_row.iter()) {
137                *d += *s;
138            }
139        }
140        let inv_n = 1.0 / n;
141        for v in dst.iter_mut() {
142            *v *= inv_n;
143        }
144    };
145
146    #[cfg(feature = "rayon")]
147    {
148        let scratch_view: &[f32] = scratch;
149        resp.par_chunks_mut(w)
150            .enumerate()
151            .for_each(|(y, dst)| vert_kernel(y, dst, scratch_view));
152    }
153    #[cfg(not(feature = "rayon"))]
154    {
155        let scratch_view: &[f32] = scratch;
156        for (y, dst) in resp.chunks_mut(w).enumerate() {
157            vert_kernel(y, dst, scratch_view);
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn fit_peak_frac_symmetric_parabola_is_zero() {
168        // y = -x² + 1 sampled at ±1 and 0 → peak at 0.
169        let f = fit_peak_frac(0.0, 1.0, 0.0, PeakFitMode::Parabolic);
170        assert!(f.abs() < 1e-6, "expected 0.0, got {f}");
171    }
172
173    #[test]
174    fn fit_peak_frac_shifted_parabola_recovers_offset() {
175        // y = -(x - 0.25)² + 1 sampled at x = -1, 0, 1.
176        let y_of = |x: f32| -(x - 0.25).powi(2) + 1.0;
177        let f = fit_peak_frac(y_of(-1.0), y_of(0.0), y_of(1.0), PeakFitMode::Parabolic);
178        assert!((f - 0.25).abs() < 1e-5, "expected 0.25, got {f}");
179    }
180
181    #[test]
182    fn fit_peak_frac_gaussian_mode_handles_log() {
183        // Pure Gaussian: y = exp(-(x-0.2)²/(2·0.5²)).
184        let g = |x: f32| (-((x - 0.2f32).powi(2)) / 0.5).exp();
185        let f = fit_peak_frac(g(-1.0), g(0.0), g(1.0), PeakFitMode::Gaussian);
186        assert!((f - 0.2).abs() < 1e-5, "Gaussian-log fit off: {f}");
187    }
188
189    #[test]
190    fn fit_peak_frac_rejects_non_maximum() {
191        // Non-maximum (denominator ≥ 0) → 0.0 fallback.
192        let f = fit_peak_frac(1.0, 0.5, 1.0, PeakFitMode::Parabolic);
193        assert_eq!(f, 0.0);
194    }
195
196    #[test]
197    fn fit_peak_frac_gaussian_falls_back_on_nonpositive() {
198        // Any ≤0 sample → parabolic branch.
199        let parab = fit_peak_frac(-0.5, 2.0, 1.5, PeakFitMode::Parabolic);
200        let gauss = fit_peak_frac(-0.5, 2.0, 1.5, PeakFitMode::Gaussian);
201        assert_eq!(parab, gauss);
202    }
203
204    #[test]
205    fn box_blur_zero_radius_is_identity() {
206        let side = 5usize;
207        let mut resp: Vec<f32> = (0..(side * side)).map(|i| i as f32).collect();
208        let before = resp.clone();
209        let mut scratch = vec![0.0; side * side];
210        box_blur_inplace(&mut resp, &mut scratch, side, side, 0);
211        assert_eq!(resp, before);
212    }
213
214    #[test]
215    fn box_blur_smooths_impulse() {
216        let side = 5usize;
217        let mut resp = vec![0.0f32; side * side];
218        let mut scratch = vec![0.0f32; side * side];
219        let mid = side / 2;
220        resp[mid * side + mid] = 9.0;
221        box_blur_inplace(&mut resp, &mut scratch, side, side, 1);
222        // 3×3 blur of an impulse = 9/9 = 1 at the center.
223        assert!(
224            (resp[mid * side + mid] - 1.0).abs() < 1e-6,
225            "center after blur = {}",
226            resp[mid * side + mid]
227        );
228    }
229
230    #[test]
231    fn box_blur_preserves_constant_field() {
232        let side = 7usize;
233        let mut resp = vec![3.5f32; side * side];
234        let mut scratch = vec![0.0f32; side * side];
235        box_blur_inplace(&mut resp, &mut scratch, side, side, 1);
236        for v in &resp {
237            assert!((v - 3.5).abs() < 1e-6);
238        }
239    }
240
241    #[test]
242    fn box_blur_handles_rectangular_grid() {
243        // Regression: the detector calls the blur on a w×h response
244        // map where w != h. Previous signature took a single `side`
245        // parameter and panicked out-of-bounds on non-square inputs.
246        let w = 8usize;
247        let h = 5usize;
248        let mut resp = vec![2.0f32; w * h];
249        let mut scratch = vec![0.0f32; w * h];
250        box_blur_inplace(&mut resp, &mut scratch, w, h, 1);
251        for v in &resp {
252            assert!((v - 2.0).abs() < 1e-6);
253        }
254    }
255}