Skip to main content

chess_corners_core/
detect.rs

1//! Corner detection utilities built on top of the dense ChESS response map.
2use crate::descriptor::{corners_to_descriptors, Corner, CornerDescriptor};
3use crate::imageview::ImageView;
4use crate::refine::{CornerRefiner, RefineContext, RefineStatus, Refiner};
5use crate::response::chess_response_u8;
6use crate::{ChessParams, ResponseMap};
7
8#[cfg(feature = "tracing")]
9use tracing::instrument;
10
11/// Compute corners starting from an 8-bit grayscale image.
12///
13/// This is a convenience that combines:
14/// - chess_response_u8 (dense response map)
15/// - thresholding + NMS
16/// - subpixel refinement driven by [`ChessParams::refiner`]
17pub fn find_corners_u8(
18    img: &[u8],
19    w: usize,
20    h: usize,
21    params: &ChessParams,
22) -> Vec<CornerDescriptor> {
23    let mut refiner = Refiner::from_kind(params.refiner.clone());
24    find_corners_u8_with_refiner(img, w, h, params, &mut refiner)
25}
26
27/// Compute corners starting from an 8-bit grayscale image using a custom refiner.
28pub fn find_corners_u8_with_refiner(
29    img: &[u8],
30    w: usize,
31    h: usize,
32    params: &ChessParams,
33    refiner: &mut dyn CornerRefiner,
34) -> Vec<CornerDescriptor> {
35    let resp = chess_response_u8(img, w, h, params);
36    let image =
37        ImageView::from_u8_slice(w, h, img).expect("image dimensions must match buffer length");
38    let corners = detect_corners_from_response_with_refiner(&resp, params, Some(image), refiner);
39    let desc_radius = params.descriptor_ring_radius();
40    corners_to_descriptors(img, w, h, desc_radius, corners)
41}
42
43/// Core detector: run NMS + refinement on an existing response map.
44///
45/// Useful if you want to reuse the response map for debugging or tuning. Honors
46/// relative vs absolute thresholds, enforces the configurable NMS radius, and
47/// rejects isolated responses via `min_cluster_size`.
48pub fn detect_corners_from_response(resp: &ResponseMap, params: &ChessParams) -> Vec<Corner> {
49    let mut refiner = Refiner::from_kind(params.refiner.clone());
50    detect_corners_from_response_with_refiner(resp, params, None, &mut refiner)
51}
52
53/// Detector variant that accepts a user-provided refiner implementation.
54pub fn detect_corners_from_response_with_refiner(
55    resp: &ResponseMap,
56    params: &ChessParams,
57    image: Option<ImageView<'_>>,
58    refiner: &mut dyn CornerRefiner,
59) -> Vec<Corner> {
60    detect_corners_from_response_impl(resp, params, image, refiner)
61}
62
63#[cfg_attr(
64    feature = "tracing",
65    instrument(level = "debug", skip(resp, params, image, refiner), fields(w = resp.w, h = resp.h))
66)]
67fn detect_corners_from_response_impl(
68    resp: &ResponseMap,
69    params: &ChessParams,
70    image: Option<ImageView<'_>>,
71    refiner: &mut dyn CornerRefiner,
72) -> Vec<Corner> {
73    let w = resp.w;
74    let h = resp.h;
75
76    if w == 0 || h == 0 {
77        return Vec::new();
78    }
79
80    // Compute global max response to derive relative threshold
81    let mut max_r = f32::NEG_INFINITY;
82    for &v in &resp.data {
83        if v > max_r {
84            max_r = v;
85        }
86    }
87    if !max_r.is_finite() {
88        return Vec::new();
89    }
90
91    let mut thr = params.threshold_abs.unwrap_or(params.threshold_rel * max_r);
92
93    if thr < 0.0 {
94        // Don’t use a negative threshold; that would accept noise.
95        thr = 0.0;
96    }
97
98    let nms_r = params.nms_radius as i32;
99    let refine_r = refiner.radius();
100    let ring_r = params.ring_radius() as i32;
101
102    // We need to stay away from the borders enough to:
103    // - have a full NMS window
104    // - have a full refinement window
105    // The response map itself is valid in [ring_r .. w-ring_r), but
106    // we don't want to sample outside [0..w/h) during refinement.
107    let border = (ring_r + nms_r + refine_r).max(0) as usize;
108
109    if w <= 2 * border || h <= 2 * border {
110        return Vec::new();
111    }
112
113    let mut corners = Vec::new();
114    let ctx = RefineContext {
115        image,
116        response: Some(resp),
117    };
118
119    for y in border..(h - border) {
120        for x in border..(w - border) {
121            let v = resp.at(x, y);
122            if v < thr {
123                continue;
124            }
125
126            // Local maximum in NMS window
127            if !is_local_max(resp, x, y, nms_r, v) {
128                continue;
129            }
130
131            // Reject isolated pixels: require a minimum number of positive
132            // neighbors in the same NMS window.
133            let cluster_size = count_positive_neighbors(resp, x, y, nms_r);
134            if cluster_size < params.min_cluster_size {
135                continue;
136            }
137
138            let seed_xy = [x as f32, y as f32];
139            let res = refiner.refine(seed_xy, ctx);
140
141            if matches!(res.status, RefineStatus::Accepted) {
142                corners.push(Corner {
143                    x: res.x,
144                    y: res.y,
145                    strength: v,
146                });
147            }
148        }
149    }
150
151    corners
152}
153
154fn is_local_max(resp: &ResponseMap, x: usize, y: usize, r: i32, v: f32) -> bool {
155    let w = resp.w as i32;
156    let h = resp.h as i32;
157    let cx = x as i32;
158    let cy = y as i32;
159
160    for dy in -r..=r {
161        for dx in -r..=r {
162            if dx == 0 && dy == 0 {
163                continue;
164            }
165            let xx = cx + dx;
166            let yy = cy + dy;
167            if xx < 0 || yy < 0 || xx >= w || yy >= h {
168                continue;
169            }
170            let vv = resp.at(xx as usize, yy as usize);
171            if vv > v {
172                return false;
173            }
174        }
175    }
176    true
177}
178
179fn count_positive_neighbors(resp: &ResponseMap, x: usize, y: usize, r: i32) -> u32 {
180    let w = resp.w as i32;
181    let h = resp.h as i32;
182    let cx = x as i32;
183    let cy = y as i32;
184    let mut count = 0;
185
186    for dy in -r..=r {
187        for dx in -r..=r {
188            if dx == 0 && dy == 0 {
189                continue;
190            }
191            let xx = cx + dx;
192            let yy = cy + dy;
193            if xx < 0 || yy < 0 || xx >= w || yy >= h {
194                continue;
195            }
196            let vv = resp.at(xx as usize, yy as usize);
197            if vv > 0.0 {
198                count += 1;
199            }
200        }
201    }
202
203    count
204}
205
206/// Merge corners within a given radius, keeping the strongest response.
207#[cfg_attr(feature = "tracing", instrument(level = "info", skip(corners)))]
208pub fn merge_corners_simple(corners: &mut Vec<Corner>, radius: f32) -> Vec<Corner> {
209    let r2 = radius * radius;
210    let mut out: Vec<Corner> = Vec::new();
211
212    // naive O(N^2) for now; N is small for a single chessboard frame
213    'outer: for c in corners.drain(..) {
214        for o in &mut out {
215            let dx = c.x - o.x;
216            let dy = c.y - o.y;
217            if dx * dx + dy * dy <= r2 {
218                // keep the stronger
219                if c.strength > o.strength {
220                    *o = c;
221                }
222                continue 'outer;
223            }
224        }
225        out.push(c);
226    }
227
228    out
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::refine::{
235        CenterOfMassConfig, CenterOfMassRefiner, RefineContext, RefineStatus, RefinerKind,
236    };
237    use image::{GrayImage, Luma};
238
239    fn make_quadrant_corner(size: u32, dark: u8, bright: u8) -> GrayImage {
240        let mut img = GrayImage::from_pixel(size, size, Luma([dark]));
241        let mid = size / 2;
242        for y in 0..size {
243            for x in 0..size {
244                let in_top = y < mid;
245                let in_left = x < mid;
246                if in_top ^ in_left {
247                    img.put_pixel(x, y, Luma([bright]));
248                }
249            }
250        }
251        img
252    }
253
254    #[test]
255    fn descriptors_report_orientation_is_stable() {
256        let size = 32u32;
257        let params = ChessParams {
258            threshold_rel: 0.01,
259            ..Default::default()
260        };
261
262        let img = make_quadrant_corner(size, 20, 220);
263        let corners = find_corners_u8(img.as_raw(), size as usize, size as usize, &params);
264        assert!(!corners.is_empty(), "expected at least one descriptor");
265
266        let best = corners
267            .iter()
268            .max_by(|a, b| a.response.partial_cmp(&b.response).unwrap())
269            .expect("non-empty");
270
271        // Expect orientation roughly aligned with a 45° grid (multiples of PI/4).
272        let k = (best.orientation / core::f32::consts::FRAC_PI_4).round();
273        let nearest = k * core::f32::consts::FRAC_PI_4;
274        let near_axis = (best.orientation - nearest).abs() < 0.35;
275        assert!(near_axis, "unexpected orientation {}", best.orientation);
276
277        let mut brighter = img.clone();
278        for p in brighter.pixels_mut() {
279            p[0] = p[0].saturating_add(5);
280        }
281
282        let brighter_corners =
283            find_corners_u8(brighter.as_raw(), size as usize, size as usize, &params);
284        assert!(!brighter_corners.is_empty());
285        let best_brighter = brighter_corners
286            .iter()
287            .max_by(|a, b| a.response.partial_cmp(&b.response).unwrap())
288            .expect("non-empty brighter");
289
290        assert!((best.x - best_brighter.x).abs() < 0.5 && (best.y - best_brighter.y).abs() < 0.5);
291
292        let dtheta = (best.orientation - best_brighter.orientation).abs();
293        let dtheta = dtheta.min(core::f32::consts::PI - dtheta);
294        assert!(
295            dtheta < 0.35,
296            "unexpected orientation delta after brightness shift: {dtheta}"
297        );
298    }
299
300    #[test]
301    fn default_refiner_matches_center_of_mass() {
302        let mut resp = ResponseMap {
303            w: 32,
304            h: 32,
305            data: vec![0.0; 32 * 32],
306        };
307
308        let cx = 16usize;
309        let cy = 16usize;
310        let w = resp.w;
311
312        resp.data[cy * w + cx] = 10.0;
313        resp.data[cy * w + (cx + 1)] = 6.0;
314        resp.data[(cy + 1) * w + cx] = 5.0;
315        resp.data[(cy + 1) * w + (cx + 1)] = 4.0;
316
317        let params = ChessParams {
318            threshold_rel: 0.01,
319            ..Default::default()
320        };
321
322        let mut refiner = CenterOfMassRefiner::new(CenterOfMassConfig::default());
323        let ctx = RefineContext {
324            image: None,
325            response: Some(&resp),
326        };
327        let expected = refiner.refine([cx as f32, cy as f32], ctx);
328        assert_eq!(expected.status, RefineStatus::Accepted);
329
330        let corners = detect_corners_from_response(&resp, &params);
331        assert_eq!(corners.len(), 1);
332        let c = &corners[0];
333        assert!((c.x - expected.x).abs() < 1e-6);
334        assert!((c.y - expected.y).abs() < 1e-6);
335    }
336
337    #[test]
338    fn params_refiner_controls_margin() {
339        let mut resp = ResponseMap {
340            w: 30,
341            h: 30,
342            data: vec![0.0; 30 * 30],
343        };
344
345        let cx = 10usize;
346        let cy = 10usize;
347        let w = resp.w;
348
349        resp.data[cy * w + cx] = 10.0;
350        resp.data[cy * w + (cx + 1)] = 1.0;
351        resp.data[(cy + 1) * w + cx] = 1.0;
352
353        let mut params = ChessParams {
354            threshold_abs: Some(0.5),
355            threshold_rel: 0.0,
356            ..Default::default()
357        };
358
359        let baseline = detect_corners_from_response(&resp, &params);
360        assert_eq!(baseline.len(), 1, "expected baseline detection");
361
362        params.refiner = RefinerKind::CenterOfMass(CenterOfMassConfig { radius: 4 });
363        let shrunk = detect_corners_from_response(&resp, &params);
364        assert!(
365            shrunk.is_empty(),
366            "larger refiner radius should increase border and skip the corner"
367        );
368    }
369
370    #[test]
371    fn merge_corners_prefers_stronger_entries() {
372        let mut corners = vec![
373            Corner {
374                x: 10.0,
375                y: 10.0,
376                strength: 1.0,
377            },
378            Corner {
379                x: 11.0,
380                y: 11.0,
381                strength: 5.0,
382            },
383            Corner {
384                x: 20.0,
385                y: 20.0,
386                strength: 3.0,
387            },
388        ];
389        let merged = merge_corners_simple(&mut corners, 2.5);
390        assert_eq!(merged.len(), 2);
391        assert!(merged.iter().any(|c| (c.x - 11.0).abs() < 1e-6
392            && (c.y - 11.0).abs() < 1e-6
393            && (c.strength - 5.0).abs() < 1e-6));
394        assert!(merged
395            .iter()
396            .any(|c| (c.x - 20.0).abs() < 1e-6 && (c.y - 20.0).abs() < 1e-6));
397    }
398}