Skip to main content

chess_corners_core/detect/chess/
detect.rs

1//! Corner detection utilities built on top of the dense ChESS response map.
2use super::response::chess_response_u8;
3use crate::detect::{count_positive_neighbors, is_local_max, Corner, CornerDescriptor};
4use crate::imageview::ImageView;
5use crate::orientation::describe_corners;
6use crate::refine::{CornerRefiner, RefineContext, RefineStatus, Refiner};
7use crate::{ChessParams, ResponseMap};
8
9#[cfg(feature = "tracing")]
10use tracing::instrument;
11
12/// Compute corners starting from an 8-bit grayscale image.
13///
14/// This is a convenience that combines:
15/// - chess_response_u8 (dense response map)
16/// - thresholding + NMS
17/// - subpixel refinement driven by [`ChessParams::refiner`]
18///
19/// # Panics
20///
21/// Panics if `img.len() != w * h`.
22pub fn find_corners_u8(
23    img: &[u8],
24    w: usize,
25    h: usize,
26    params: &ChessParams,
27) -> Vec<CornerDescriptor> {
28    assert_eq!(
29        img.len(),
30        w * h,
31        "find_corners_u8: img.len() ({}) must equal w*h ({w} * {h} = {})",
32        img.len(),
33        w * h,
34    );
35    let mut refiner = Refiner::from_kind(params.refiner.clone());
36    find_corners_u8_with_refiner(img, w, h, params, &mut refiner)
37}
38
39/// Compute corners starting from an 8-bit grayscale image using a custom refiner.
40pub(crate) fn find_corners_u8_with_refiner(
41    img: &[u8],
42    w: usize,
43    h: usize,
44    params: &ChessParams,
45    refiner: &mut dyn CornerRefiner,
46) -> Vec<CornerDescriptor> {
47    let resp = chess_response_u8(img, w, h, params);
48    let image =
49        ImageView::from_u8_slice(w, h, img).expect("image dimensions must match buffer length");
50    let corners = detect_corners_from_response_with_refiner(&resp, params, Some(image), refiner);
51    let desc_radius = params.ring_radius();
52    describe_corners(img, w, h, desc_radius, corners, params.orientation_method)
53}
54
55/// Core detector: run NMS + refinement on an existing response map.
56///
57/// Useful if you want to reuse the response map for debugging or tuning. Honors
58/// the absolute response threshold, enforces the configurable NMS radius, and
59/// rejects isolated responses via `min_cluster_size`.
60pub fn detect_corners_from_response(resp: &ResponseMap, params: &ChessParams) -> Vec<Corner> {
61    let mut refiner = Refiner::from_kind(params.refiner.clone());
62    detect_corners_from_response_with_refiner(resp, params, None, &mut refiner)
63}
64
65/// Detector variant that accepts a user-provided refiner implementation.
66///
67/// Wires [`detect_peaks_from_response_with_refine_radius`] (stage 1:
68/// threshold + NMS + cluster-filter on the response map) into
69/// [`refine_corners_on_image`] (stage 2: image-domain subpixel
70/// refinement). The two stages are available individually for callers
71/// that want to inspect or replace either half.
72pub fn detect_corners_from_response_with_refiner(
73    resp: &ResponseMap,
74    params: &ChessParams,
75    image: Option<ImageView<'_>>,
76    refiner: &mut dyn CornerRefiner,
77) -> Vec<Corner> {
78    let peaks = detect_peaks_from_response_with_refine_radius(resp, params, refiner.radius());
79    refine_corners_on_image(peaks, image, Some(resp), refiner)
80}
81
82/// Stage 1 of ChESS detection: threshold + NMS + cluster-filter on the
83/// response map.
84///
85/// Returns peaks at integer coordinates (cast to `f32`) with the raw
86/// response value as `strength`. The refiner is **not** consulted at
87/// this stage — image-domain subpixel refinement runs separately in
88/// [`refine_corners_on_image`].
89///
90/// The border margin accounts for the ring radius and the NMS window,
91/// plus `refine_radius` extra pixels so that an image-domain refiner
92/// with the given patch half-width can safely operate on every
93/// accepted peak. Pass `refine_radius = 0` when no downstream
94/// image-domain refiner needs the extra border.
95///
96/// Used by the [`DenseDetector`](crate::DenseDetector) trait
97/// implementor for ChESS, which threads the refiner radius from the
98/// orchestrator into peak detection so the response and refinement
99/// stages address the same valid pixel region. See
100/// [`detect_corners_from_response_with_refiner`] for the fused
101/// variant that combines both stages.
102#[cfg_attr(
103    feature = "tracing",
104    instrument(level = "debug", skip(resp, params), fields(w = resp.w, h = resp.h))
105)]
106pub fn detect_peaks_from_response_with_refine_radius(
107    resp: &ResponseMap,
108    params: &ChessParams,
109    refine_radius: i32,
110) -> Vec<Corner> {
111    let w = resp.w;
112    let h = resp.h;
113
114    if w == 0 || h == 0 {
115        return Vec::new();
116    }
117
118    // Absolute response floor. The paper's acceptance criterion is
119    // "R > 0", so we use a strict comparison below; the default
120    // `threshold = 0.0` reduces to "accept any strictly positive
121    // response", the paper's contract.
122    let thr = params.threshold.max(0.0);
123
124    let nms_r = params.nms_radius as i32;
125    let ring_r = params.ring_radius() as i32;
126
127    // We need to stay away from the borders enough to:
128    // - have a full NMS window
129    // - have a full refinement window (when chained with a refiner)
130    // The response map itself is valid in [ring_r .. w-ring_r), but
131    // we don't want to sample outside [0..w/h) during refinement.
132    let border = (ring_r + nms_r + refine_radius).max(0) as usize;
133
134    if w <= 2 * border || h <= 2 * border {
135        return Vec::new();
136    }
137
138    let mut corners = Vec::new();
139
140    for y in border..(h - border) {
141        for x in border..(w - border) {
142            let v = resp.at(x, y);
143            if v <= thr {
144                continue;
145            }
146
147            // Local maximum in NMS window
148            if !is_local_max(resp.data(), resp.w, resp.h, x, y, nms_r, v) {
149                continue;
150            }
151
152            // Reject isolated pixels: require a minimum number of positive
153            // neighbors in the same NMS window.
154            let cluster_size = count_positive_neighbors(resp.data(), resp.w, resp.h, x, y, nms_r);
155            if cluster_size < params.min_cluster_size {
156                continue;
157            }
158
159            corners.push(Corner {
160                x: x as f32,
161                y: y as f32,
162                strength: v,
163            });
164        }
165    }
166
167    corners
168}
169
170/// Stage 2 of detection: image-domain subpixel refinement.
171///
172/// Detector-agnostic: works on any `Vec<Corner>` regardless of whether
173/// the peaks came from the ChESS or Radon detector. Each input peak
174/// is fed to `refiner` with a [`RefineContext`] containing the image
175/// view and the optional response map. Peaks the refiner rejects
176/// (status not [`RefineStatus::Accepted`]) are dropped from the
177/// output; accepted peaks are emitted with their refined subpixel
178/// `(x, y)` and the **input** `strength` (the refiner does not
179/// rescore the peak strength).
180///
181/// Iteration order matches the order of the input vector — necessary
182/// for downstream stages that assume a stable scan order.
183#[cfg_attr(
184    feature = "tracing",
185    instrument(level = "debug", skip(corners, image, response, refiner))
186)]
187pub fn refine_corners_on_image(
188    corners: Vec<Corner>,
189    image: Option<ImageView<'_>>,
190    response: Option<&ResponseMap>,
191    refiner: &mut dyn CornerRefiner,
192) -> Vec<Corner> {
193    if corners.is_empty() {
194        return Vec::new();
195    }
196
197    let ctx = RefineContext { image, response };
198    let mut out = Vec::with_capacity(corners.len());
199    for c in corners {
200        let seed_xy = [c.x, c.y];
201        let res = refiner.refine(seed_xy, ctx);
202        if matches!(res.status, RefineStatus::Accepted) {
203            out.push(Corner {
204                x: res.x,
205                y: res.y,
206                strength: c.strength,
207            });
208        }
209    }
210    out
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::refine::{
217        CenterOfMassConfig, CenterOfMassRefiner, RefineContext, RefineStatus, RefinerKind,
218    };
219    use image::{GrayImage, Luma};
220
221    fn make_quadrant_corner(size: u32, dark: u8, bright: u8) -> GrayImage {
222        let mut img = GrayImage::from_pixel(size, size, Luma([dark]));
223        let mid = size / 2;
224        for y in 0..size {
225            for x in 0..size {
226                let in_top = y < mid;
227                let in_left = x < mid;
228                if in_top ^ in_left {
229                    img.put_pixel(x, y, Luma([bright]));
230                }
231            }
232        }
233        img
234    }
235
236    #[test]
237    #[should_panic(expected = "find_corners_u8: img.len()")]
238    fn find_corners_u8_panics_on_dimension_mismatch() {
239        let img = vec![0u8; 10];
240        let params = ChessParams::default();
241        let _ = find_corners_u8(&img, 4, 4, &params);
242    }
243
244    #[test]
245    fn descriptors_report_two_axes_stable() {
246        use core::f32::consts::{FRAC_PI_2, PI};
247
248        let size = 32u32;
249        let params = ChessParams::default();
250
251        let img = make_quadrant_corner(size, 20, 220);
252        let corners = find_corners_u8(img.as_raw(), size as usize, size as usize, &params);
253        assert!(!corners.is_empty(), "expected at least one descriptor");
254
255        let best = corners
256            .iter()
257            .max_by(|a, b| a.response.partial_cmp(&b.response).unwrap())
258            .expect("non-empty");
259        let best_axes = best.axes.expect("orientation enabled (default config)");
260
261        // axes[0] in [0, π), axes[1] in (axes[0], axes[0] + π)
262        assert!(best_axes[0].angle >= 0.0 && best_axes[0].angle < PI);
263        assert!(
264            best_axes[1].angle > best_axes[0].angle && best_axes[1].angle < best_axes[0].angle + PI
265        );
266
267        // The quadrant corner has one axis horizontal (line angle 0)
268        // and one vertical (line angle π/2). Accept a generous tolerance
269        // because the 32×32 synthetic image is aliased.
270        let near_line = |x: f32, target: f32| -> f32 {
271            let xr = x.rem_euclid(PI);
272            let tr = target.rem_euclid(PI);
273            let d = (xr - tr).abs();
274            d.min(PI - d)
275        };
276        // One of the two axes matches horizontal (line 0), the other vertical (line π/2).
277        let horiz = near_line(best_axes[0].angle, 0.0).min(near_line(best_axes[1].angle, 0.0));
278        let vert =
279            near_line(best_axes[0].angle, FRAC_PI_2).min(near_line(best_axes[1].angle, FRAC_PI_2));
280        assert!(
281            horiz < 0.35,
282            "horiz line miss: {horiz}, axes {:?}",
283            best_axes
284        );
285        assert!(vert < 0.35, "vert line miss: {vert}, axes {:?}", best_axes);
286
287        // Brightness shift stability: both axes survive a global
288        // intensity offset.
289        let mut brighter = img.clone();
290        for p in brighter.pixels_mut() {
291            p[0] = p[0].saturating_add(5);
292        }
293
294        let brighter_corners =
295            find_corners_u8(brighter.as_raw(), size as usize, size as usize, &params);
296        assert!(!brighter_corners.is_empty());
297        let best_brighter = brighter_corners
298            .iter()
299            .max_by(|a, b| a.response.partial_cmp(&b.response).unwrap())
300            .expect("non-empty brighter");
301        let best_brighter_axes = best_brighter
302            .axes
303            .expect("orientation enabled (default config)");
304
305        assert!((best.x - best_brighter.x).abs() < 0.5 && (best.y - best_brighter.y).abs() < 0.5);
306
307        let da0 = near_line(best_axes[0].angle, best_brighter_axes[0].angle);
308        let da1 = near_line(best_axes[1].angle, best_brighter_axes[1].angle);
309        assert!(da0 < 0.35, "axis0 delta after brightness shift: {da0}");
310        assert!(da1 < 0.35, "axis1 delta after brightness shift: {da1}");
311    }
312
313    #[test]
314    fn default_refiner_matches_center_of_mass() {
315        let mut resp = ResponseMap {
316            w: 32,
317            h: 32,
318            data: vec![0.0; 32 * 32],
319        };
320
321        let cx = 16usize;
322        let cy = 16usize;
323        let w = resp.w;
324
325        resp.data[cy * w + cx] = 10.0;
326        resp.data[cy * w + (cx + 1)] = 6.0;
327        resp.data[(cy + 1) * w + cx] = 5.0;
328        resp.data[(cy + 1) * w + (cx + 1)] = 4.0;
329
330        let params = ChessParams::default();
331
332        let mut refiner = CenterOfMassRefiner::new(CenterOfMassConfig::default());
333        let ctx = RefineContext {
334            image: None,
335            response: Some(&resp),
336        };
337        let expected = refiner.refine([cx as f32, cy as f32], ctx);
338        assert_eq!(expected.status, RefineStatus::Accepted);
339
340        let corners = detect_corners_from_response(&resp, &params);
341        assert_eq!(corners.len(), 1);
342        let c = &corners[0];
343        assert!((c.x - expected.x).abs() < 1e-6);
344        assert!((c.y - expected.y).abs() < 1e-6);
345    }
346
347    #[test]
348    fn params_refiner_controls_margin() {
349        let mut resp = ResponseMap {
350            w: 30,
351            h: 30,
352            data: vec![0.0; 30 * 30],
353        };
354
355        let cx = 10usize;
356        let cy = 10usize;
357        let w = resp.w;
358
359        resp.data[cy * w + cx] = 10.0;
360        resp.data[cy * w + (cx + 1)] = 1.0;
361        resp.data[(cy + 1) * w + cx] = 1.0;
362
363        let mut params = ChessParams {
364            threshold: 0.5,
365            ..Default::default()
366        };
367
368        let baseline = detect_corners_from_response(&resp, &params);
369        assert_eq!(baseline.len(), 1, "expected baseline detection");
370
371        params.refiner = RefinerKind::CenterOfMass(CenterOfMassConfig { radius: 4 });
372        let shrunk = detect_corners_from_response(&resp, &params);
373        assert!(
374            shrunk.is_empty(),
375            "larger refiner radius should increase border and skip the corner"
376        );
377    }
378}