chess_corners_core/detect/chess/
detect.rs1use 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
12pub 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
39pub(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
55pub 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
65pub 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#[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 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 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 if !is_local_max(resp.data(), resp.w, resp.h, x, y, nms_r, v) {
149 continue;
150 }
151
152 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#[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, ¶ms);
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, ¶ms);
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 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 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 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 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, ¶ms);
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, ¶ms);
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, ¶ms);
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, ¶ms);
373 assert!(
374 shrunk.is_empty(),
375 "larger refiner radius should increase border and skip the corner"
376 );
377 }
378}