Skip to main content

chess_corners_core/detect/radon/
detect.rs

1//! Peak detection over a Radon response map.
2//!
3//! Applies threshold / NMS / min-cluster rejection on the
4//! working-resolution response from
5//! [`super::response::radon_response_u8`], then a 3-point peak fit on
6//! the blurred response to get subpixel offsets. Output coordinates
7//! are divided by `image_upsample` so they sit in the **input pixel
8//! frame**.
9
10use super::primitives::fit_peak_frac;
11use super::response::{RadonDetectorParams, RadonResponseView};
12use crate::detect::{count_positive_neighbors, is_local_max, Corner};
13#[cfg(feature = "rayon")]
14use rayon::prelude::*;
15
16/// Stage 1 of Radon detection: threshold + NMS + cluster-filter +
17/// 3-point Gaussian peak-fit on the response map.
18///
19/// The 3-point Gaussian peak-fit is a **response-map** subpixel
20/// operation (not image-domain refinement), so it stays inside this
21/// stage. The output is already subpixel in the input-image frame
22/// (positions are divided by `image_upsample`). Image-domain
23/// refinement against the input pixels is the caller's choice and
24/// lives in [`crate::refine_corners_on_image`].
25pub fn detect_peaks_from_radon(
26    resp: &RadonResponseView<'_>,
27    params: &RadonDetectorParams,
28) -> Vec<Corner> {
29    let w = resp.width();
30    let h = resp.height();
31    if w == 0 || h == 0 {
32        return Vec::new();
33    }
34
35    let data = resp.data();
36
37    let mut max_r = f32::NEG_INFINITY;
38    for &v in data {
39        if v > max_r {
40            max_r = v;
41        }
42    }
43    if !max_r.is_finite() {
44        return Vec::new();
45    }
46
47    let thr = (params.threshold_rel * max_r).max(0.0);
48
49    let nms_r = params.nms_radius as i32;
50    let ray_r = params.ray_radius_clamped() as i32;
51    // We already zeroed pixels < ray_r from the border; leave 1 extra
52    // pixel for the 3-point peak fit.
53    let border = (ray_r + nms_r + 1).max(0) as usize;
54    if w <= 2 * border || h <= 2 * border {
55        return Vec::new();
56    }
57
58    let inv_up = 1.0 / (params.image_upsample_clamped() as f32);
59    let min_cluster = params.min_cluster_size;
60    let peak_fit = params.peak_fit;
61
62    #[inline(always)]
63    fn at(data: &[f32], w: usize, x: usize, y: usize) -> f32 {
64        data[y * w + x]
65    }
66
67    let process_row = |y: usize, sink: &mut Vec<Corner>| {
68        for x in border..(w - border) {
69            let v = at(data, w, x, y);
70            if v <= thr {
71                continue;
72            }
73            if !is_local_max(data, w, h, x, y, nms_r, v) {
74                continue;
75            }
76            if count_positive_neighbors(data, w, h, x, y, nms_r) < min_cluster {
77                continue;
78            }
79
80            let r_c = v;
81            let r_xm = at(data, w, x - 1, y);
82            let r_xp = at(data, w, x + 1, y);
83            let r_ym = at(data, w, x, y - 1);
84            let r_yp = at(data, w, x, y + 1);
85            let fx = fit_peak_frac(r_xm, r_c, r_xp, peak_fit);
86            let fy = fit_peak_frac(r_ym, r_c, r_yp, peak_fit);
87
88            let gx = (x as f32 + fx) * inv_up;
89            let gy = (y as f32 + fy) * inv_up;
90            sink.push(Corner {
91                x: gx,
92                y: gy,
93                strength: v,
94            });
95        }
96    };
97
98    #[cfg(feature = "rayon")]
99    {
100        let row_results: Vec<Vec<Corner>> = (border..(h - border))
101            .into_par_iter()
102            .map(|y| {
103                let mut sink = Vec::new();
104                process_row(y, &mut sink);
105                sink
106            })
107            .collect();
108        let total: usize = row_results.iter().map(|r| r.len()).sum();
109        let mut out = Vec::with_capacity(total);
110        for row in row_results {
111            out.extend(row);
112        }
113        out
114    }
115    #[cfg(not(feature = "rayon"))]
116    {
117        let mut out = Vec::new();
118        for y in border..(h - border) {
119            process_row(y, &mut out);
120        }
121        out
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::super::response::{radon_response_u8, RadonBuffers, RadonDetectorParams};
128    use super::super::test_fixtures::synthetic_chessboard_aa;
129    use super::*;
130
131    #[test]
132    fn detector_recovers_interior_corner_without_seed() {
133        const SIZE: usize = 65;
134        const CELL: usize = 8;
135        let offset = (32.35, 32.8);
136        let img = synthetic_chessboard_aa(SIZE, CELL, offset, 30, 230);
137
138        let params = RadonDetectorParams {
139            image_upsample: 2,
140            ..RadonDetectorParams::default()
141        };
142        let mut buffers = RadonBuffers::new();
143        let resp = radon_response_u8(&img, SIZE, SIZE, &params, &mut buffers);
144        let corners = detect_peaks_from_radon(&resp, &params);
145
146        assert!(
147            !corners.is_empty(),
148            "expected at least one corner, got none"
149        );
150
151        let expected_x =
152            offset.0 + (((SIZE as f32 / 2.0 - offset.0) / CELL as f32).round() * CELL as f32);
153        let expected_y =
154            offset.1 + (((SIZE as f32 / 2.0 - offset.1) / CELL as f32).round() * CELL as f32);
155
156        let (best, err) = corners
157            .iter()
158            .map(|c| {
159                let dx = c.x - expected_x;
160                let dy = c.y - expected_y;
161                (c, (dx * dx + dy * dy).sqrt())
162            })
163            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
164            .expect("non-empty");
165        assert!(
166            err < 0.2,
167            "closest corner err={:.4} at ({:.3}, {:.3}), expected ({:.3}, {:.3}); {} corners found",
168            err,
169            best.x,
170            best.y,
171            expected_x,
172            expected_y,
173            corners.len(),
174        );
175    }
176
177    #[test]
178    fn detector_upsample_1_still_produces_corners() {
179        const SIZE: usize = 65;
180        const CELL: usize = 8;
181        let offset = (32.35, 32.8);
182        let img = synthetic_chessboard_aa(SIZE, CELL, offset, 30, 230);
183        let params = RadonDetectorParams {
184            image_upsample: 1,
185            ray_radius: 2,
186            nms_radius: 2,
187            ..RadonDetectorParams::default()
188        };
189        let mut buffers = RadonBuffers::new();
190        let resp = radon_response_u8(&img, SIZE, SIZE, &params, &mut buffers);
191        let corners = detect_peaks_from_radon(&resp, &params);
192        assert!(!corners.is_empty(), "upsample=1 produced no corners");
193    }
194}