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