1use 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
11pub 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
27pub 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
43pub 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
53pub 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 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 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 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 if !is_local_max(resp, x, y, nms_r, v) {
128 continue;
129 }
130
131 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#[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 '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 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, ¶ms);
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 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, ¶ms);
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, ¶ms);
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, ¶ms);
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, ¶ms);
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}