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