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::{Corner, CornerDescriptor};
4use crate::imageview::ImageView;
5use crate::orientation::{describe_corners, OrientationMethod};
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`]
18pub fn find_corners_u8(
19    img: &[u8],
20    w: usize,
21    h: usize,
22    params: &ChessParams,
23) -> Vec<CornerDescriptor> {
24    let mut refiner = Refiner::from_kind(params.refiner.clone());
25    find_corners_u8_with_refiner(img, w, h, params, &mut refiner)
26}
27
28/// Compute corners starting from an 8-bit grayscale image using a custom refiner.
29pub fn find_corners_u8_with_refiner(
30    img: &[u8],
31    w: usize,
32    h: usize,
33    params: &ChessParams,
34    refiner: &mut dyn CornerRefiner,
35) -> Vec<CornerDescriptor> {
36    let resp = chess_response_u8(img, w, h, params);
37    let image =
38        ImageView::from_u8_slice(w, h, img).expect("image dimensions must match buffer length");
39    let corners = detect_corners_from_response_with_refiner(&resp, params, Some(image), refiner);
40    let desc_radius = params.descriptor_ring_radius();
41    describe_corners(img, w, h, desc_radius, corners, OrientationMethod::RingFit)
42}
43
44/// Core detector: run NMS + refinement on an existing response map.
45///
46/// Useful if you want to reuse the response map for debugging or tuning. Honors
47/// relative vs absolute thresholds, enforces the configurable NMS radius, and
48/// rejects isolated responses via `min_cluster_size`.
49pub fn detect_corners_from_response(resp: &ResponseMap, params: &ChessParams) -> Vec<Corner> {
50    let mut refiner = Refiner::from_kind(params.refiner.clone());
51    detect_corners_from_response_with_refiner(resp, params, None, &mut refiner)
52}
53
54/// Detector variant that accepts a user-provided refiner implementation.
55///
56/// Wires [`detect_peaks_from_response`] (stage 1: threshold + NMS +
57/// cluster-filter on the response map) into [`refine_corners_on_image`]
58/// (stage 2: image-domain subpixel refinement). The two stages are
59/// available individually for callers that want to inspect or replace
60/// either half.
61pub fn detect_corners_from_response_with_refiner(
62    resp: &ResponseMap,
63    params: &ChessParams,
64    image: Option<ImageView<'_>>,
65    refiner: &mut dyn CornerRefiner,
66) -> Vec<Corner> {
67    let peaks = detect_peaks_from_response_with_refine_radius(resp, params, refiner.radius());
68    refine_corners_on_image(peaks, image, Some(resp), refiner)
69}
70
71/// Stage 1 of ChESS detection: threshold + NMS + cluster-filter on the
72/// response map.
73///
74/// Returns peaks at integer coordinates (cast to `f32`) with the raw
75/// response value as `strength`. The refiner is **not** consulted at
76/// this stage — image-domain subpixel refinement runs separately in
77/// [`refine_corners_on_image`].
78///
79/// The border margin accounts for the ring radius and the NMS window
80/// only; if a downstream refiner needs additional border, the fused
81/// helper [`detect_corners_from_response_with_refiner`] handles that
82/// by deferring to a private variant that also takes the refiner
83/// radius.
84#[cfg_attr(
85    feature = "tracing",
86    instrument(level = "debug", skip(resp, params), fields(w = resp.w, h = resp.h))
87)]
88pub fn detect_peaks_from_response(resp: &ResponseMap, params: &ChessParams) -> Vec<Corner> {
89    detect_peaks_from_response_with_refine_radius(resp, params, 0)
90}
91
92/// Stage 1 of ChESS detection: same as [`detect_peaks_from_response`]
93/// but extends the border margin by `refine_radius` extra pixels so
94/// that an image-domain refiner with the given patch half-width can
95/// safely operate on every accepted peak.
96///
97/// Used by the [`DenseDetector`](crate::DenseDetector) trait
98/// implementor for ChESS, which threads the refiner radius from the
99/// orchestrator into peak detection so the fused legacy behaviour
100/// ([`detect_corners_from_response_with_refiner`]) is preserved
101/// bit-for-bit across the split.
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    // Compute global max response to derive relative threshold
119    let mut max_r = f32::NEG_INFINITY;
120    for &v in &resp.data {
121        if v > max_r {
122            max_r = v;
123        }
124    }
125    if !max_r.is_finite() {
126        return Vec::new();
127    }
128
129    let mut thr = params.threshold_abs.unwrap_or(params.threshold_rel * max_r);
130
131    if thr < 0.0 {
132        // Don’t use a negative threshold; that would accept noise.
133        thr = 0.0;
134    }
135
136    // The paper's acceptance criterion is "R > 0", so we use a strict
137    // comparison below. With `threshold_abs = Some(0.0)` (the default)
138    // this reduces to "accept any strictly positive response", which is
139    // the paper's contract.
140
141    let nms_r = params.nms_radius as i32;
142    let ring_r = params.ring_radius() as i32;
143
144    // We need to stay away from the borders enough to:
145    // - have a full NMS window
146    // - have a full refinement window (when chained with a refiner)
147    // The response map itself is valid in [ring_r .. w-ring_r), but
148    // we don't want to sample outside [0..w/h) during refinement.
149    let border = (ring_r + nms_r + refine_radius).max(0) as usize;
150
151    if w <= 2 * border || h <= 2 * border {
152        return Vec::new();
153    }
154
155    let mut corners = Vec::new();
156
157    for y in border..(h - border) {
158        for x in border..(w - border) {
159            let v = resp.at(x, y);
160            if v <= thr {
161                continue;
162            }
163
164            // Local maximum in NMS window
165            if !is_local_max(resp.data(), resp.w, resp.h, x, y, nms_r, v) {
166                continue;
167            }
168
169            // Reject isolated pixels: require a minimum number of positive
170            // neighbors in the same NMS window.
171            let cluster_size = count_positive_neighbors(resp.data(), resp.w, resp.h, x, y, nms_r);
172            if cluster_size < params.min_cluster_size {
173                continue;
174            }
175
176            corners.push(Corner {
177                x: x as f32,
178                y: y as f32,
179                strength: v,
180            });
181        }
182    }
183
184    corners
185}
186
187/// Stage 2 of detection: image-domain subpixel refinement.
188///
189/// Detector-agnostic: works on any `Vec<Corner>` regardless of whether
190/// the peaks came from the ChESS or Radon detector. Each input peak
191/// is fed to `refiner` with a [`RefineContext`] containing the image
192/// view and the optional response map. Peaks the refiner rejects
193/// (status not [`RefineStatus::Accepted`]) are dropped from the
194/// output; accepted peaks are emitted with their refined subpixel
195/// `(x, y)` and the **input** `strength` (the refiner does not
196/// rescore the peak strength).
197///
198/// Iteration order matches the order of the input vector — necessary
199/// for downstream stages that assume a stable scan order.
200#[cfg_attr(
201    feature = "tracing",
202    instrument(level = "debug", skip(corners, image, response, refiner))
203)]
204pub fn refine_corners_on_image(
205    corners: Vec<Corner>,
206    image: Option<ImageView<'_>>,
207    response: Option<&ResponseMap>,
208    refiner: &mut dyn CornerRefiner,
209) -> Vec<Corner> {
210    if corners.is_empty() {
211        return Vec::new();
212    }
213
214    let ctx = RefineContext { image, response };
215    let mut out = Vec::with_capacity(corners.len());
216    for c in corners {
217        let seed_xy = [c.x, c.y];
218        let res = refiner.refine(seed_xy, ctx);
219        if matches!(res.status, RefineStatus::Accepted) {
220            out.push(Corner {
221                x: res.x,
222                y: res.y,
223                strength: c.strength,
224            });
225        }
226    }
227    out
228}
229
230/// Local-max NMS check over a `(2r+1)²` window on a row-major
231/// response slice. Slice-based so borrowed views (e.g. the Radon
232/// detector's working-resolution buffer) can call it without cloning
233/// into a [`ResponseMap`].
234pub(crate) fn is_local_max(
235    data: &[f32],
236    w: usize,
237    h: usize,
238    x: usize,
239    y: usize,
240    r: i32,
241    v: f32,
242) -> bool {
243    let wi = w as i32;
244    let hi = h as i32;
245    let cx = x as i32;
246    let cy = y as i32;
247
248    for dy in -r..=r {
249        for dx in -r..=r {
250            if dx == 0 && dy == 0 {
251                continue;
252            }
253            let xx = cx + dx;
254            let yy = cy + dy;
255            if xx < 0 || yy < 0 || xx >= wi || yy >= hi {
256                continue;
257            }
258            let vv = data[(yy as usize) * w + (xx as usize)];
259            if vv > v {
260                return false;
261            }
262        }
263    }
264    true
265}
266
267/// Count strictly-positive neighbors in the same window as
268/// [`is_local_max`]. See that function for the slice contract.
269pub(crate) fn count_positive_neighbors(
270    data: &[f32],
271    w: usize,
272    h: usize,
273    x: usize,
274    y: usize,
275    r: i32,
276) -> u32 {
277    let wi = w as i32;
278    let hi = h as i32;
279    let cx = x as i32;
280    let cy = y as i32;
281    let mut count = 0;
282
283    for dy in -r..=r {
284        for dx in -r..=r {
285            if dx == 0 && dy == 0 {
286                continue;
287            }
288            let xx = cx + dx;
289            let yy = cy + dy;
290            if xx < 0 || yy < 0 || xx >= wi || yy >= hi {
291                continue;
292            }
293            let vv = data[(yy as usize) * w + (xx as usize)];
294            if vv > 0.0 {
295                count += 1;
296            }
297        }
298    }
299
300    count
301}
302
303/// Merge corners within a given radius, keeping the strongest response.
304///
305/// Uses a uniform spatial grid with cell size equal to `radius`, so any
306/// two corners within `radius` of each other land in the same or
307/// neighbouring cells. For `N` input corners the expected cost is
308/// `O(N)` with small constants (vs the naive `O(N²)` pairwise scan),
309/// which matters on Radon-detected frames where `N` can run into the
310/// thousands.
311///
312/// The output is order-equivalent to the naive scan: when an incoming
313/// corner is within `radius` of multiple existing corners, the
314/// **first-seen** existing corner wins the merge — same as the
315/// previous implementation. When the incoming corner is stronger, it
316/// replaces that existing corner's position in-place.
317#[cfg_attr(feature = "tracing", instrument(level = "info", skip(corners)))]
318pub fn merge_corners_simple(corners: &mut Vec<Corner>, radius: f32) -> Vec<Corner> {
319    let n = corners.len();
320    if n == 0 {
321        return Vec::new();
322    }
323    if !radius.is_finite() || radius <= 0.0 {
324        return std::mem::take(corners);
325    }
326
327    let r2 = radius * radius;
328
329    // Bounding box of the input corner cloud.
330    let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY);
331    let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY);
332    for c in corners.iter() {
333        if c.x < min_x {
334            min_x = c.x;
335        }
336        if c.y < min_y {
337            min_y = c.y;
338        }
339        if c.x > max_x {
340            max_x = c.x;
341        }
342        if c.y > max_y {
343            max_y = c.y;
344        }
345    }
346
347    // Guard: any non-finite coordinate or zero-extent cloud falls back
348    // to the naive scan rather than building a degenerate grid.
349    if !(min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()) {
350        return merge_corners_naive(corners, r2);
351    }
352
353    let cell = radius;
354    let inv_cell = 1.0 / cell;
355    let grid_w = ((max_x - min_x) * inv_cell).floor() as usize + 1;
356    let grid_h = ((max_y - min_y) * inv_cell).floor() as usize + 1;
357
358    // Each grid cell stores indices into `out`. `u32` is plenty for
359    // realistic candidate counts and halves the per-cell footprint
360    // vs `usize` on 64-bit targets.
361    let mut grid: Vec<Vec<u32>> = vec![Vec::new(); grid_w * grid_h];
362    let mut out: Vec<Corner> = Vec::with_capacity(n);
363
364    let cell_of = |c: &Corner| -> (usize, usize) {
365        let gx = (((c.x - min_x) * inv_cell) as usize).min(grid_w - 1);
366        let gy = (((c.y - min_y) * inv_cell) as usize).min(grid_h - 1);
367        (gx, gy)
368    };
369
370    for c in corners.drain(..) {
371        let (gx, gy) = cell_of(&c);
372
373        // Scan the 3×3 cell neighbourhood for the first existing
374        // corner within `radius`. Iteration order matches the naive
375        // scan (cells visited in (y, x) order; within a cell, indices
376        // are stored in insertion order, which is the order
377        // corners were pushed into `out`).
378        let y0 = gy.saturating_sub(1);
379        let y1 = (gy + 1).min(grid_h - 1);
380        let x0 = gx.saturating_sub(1);
381        let x1 = (gx + 1).min(grid_w - 1);
382
383        let mut hit: Option<usize> = None;
384        let mut best_idx = u32::MAX;
385        for ny in y0..=y1 {
386            for nx in x0..=x1 {
387                for &idx in &grid[ny * grid_w + nx] {
388                    if idx >= best_idx {
389                        continue;
390                    }
391                    let i = idx as usize;
392                    let dx = c.x - out[i].x;
393                    let dy = c.y - out[i].y;
394                    if dx * dx + dy * dy <= r2 {
395                        best_idx = idx;
396                        hit = Some(i);
397                    }
398                }
399            }
400        }
401
402        if let Some(i) = hit {
403            if c.strength > out[i].strength {
404                let (old_gx, old_gy) = cell_of(&out[i]);
405                out[i] = c;
406                let (new_gx, new_gy) = cell_of(&out[i]);
407                if old_gx != new_gx || old_gy != new_gy {
408                    let id = i as u32;
409                    let old_cell = old_gy * grid_w + old_gx;
410                    let new_cell = new_gy * grid_w + new_gx;
411                    grid[old_cell].retain(|&j| j != id);
412                    grid[new_cell].push(id);
413                }
414            }
415        } else {
416            let new_idx = out.len() as u32;
417            let (gx, gy) = cell_of(&c);
418            out.push(c);
419            grid[gy * grid_w + gx].push(new_idx);
420        }
421    }
422
423    out
424}
425
426/// Naive O(N²) merge, used only as a fallback when the spatial-grid
427/// path can't build a valid bounding box (e.g. NaN / Inf coordinates).
428fn merge_corners_naive(corners: &mut Vec<Corner>, r2: f32) -> Vec<Corner> {
429    let mut out: Vec<Corner> = Vec::new();
430    'outer: for c in corners.drain(..) {
431        for o in &mut out {
432            let dx = c.x - o.x;
433            let dy = c.y - o.y;
434            if dx * dx + dy * dy <= r2 {
435                if c.strength > o.strength {
436                    *o = c;
437                }
438                continue 'outer;
439            }
440        }
441        out.push(c);
442    }
443    out
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::refine::{
450        CenterOfMassConfig, CenterOfMassRefiner, RefineContext, RefineStatus, RefinerKind,
451    };
452    use image::{GrayImage, Luma};
453
454    fn make_quadrant_corner(size: u32, dark: u8, bright: u8) -> GrayImage {
455        let mut img = GrayImage::from_pixel(size, size, Luma([dark]));
456        let mid = size / 2;
457        for y in 0..size {
458            for x in 0..size {
459                let in_top = y < mid;
460                let in_left = x < mid;
461                if in_top ^ in_left {
462                    img.put_pixel(x, y, Luma([bright]));
463                }
464            }
465        }
466        img
467    }
468
469    #[test]
470    fn descriptors_report_two_axes_stable() {
471        use core::f32::consts::{FRAC_PI_2, PI};
472
473        let size = 32u32;
474        let params = ChessParams {
475            threshold_rel: 0.01,
476            ..Default::default()
477        };
478
479        let img = make_quadrant_corner(size, 20, 220);
480        let corners = find_corners_u8(img.as_raw(), size as usize, size as usize, &params);
481        assert!(!corners.is_empty(), "expected at least one descriptor");
482
483        let best = corners
484            .iter()
485            .max_by(|a, b| a.response.partial_cmp(&b.response).unwrap())
486            .expect("non-empty");
487
488        // axes[0] in [0, π), axes[1] in (axes[0], axes[0] + π)
489        assert!(best.axes[0].angle >= 0.0 && best.axes[0].angle < PI);
490        assert!(
491            best.axes[1].angle > best.axes[0].angle && best.axes[1].angle < best.axes[0].angle + PI
492        );
493
494        // The quadrant corner has one axis horizontal (line angle 0)
495        // and one vertical (line angle π/2). Accept a generous tolerance
496        // because the 32×32 synthetic image is aliased.
497        let near_line = |x: f32, target: f32| -> f32 {
498            let xr = x.rem_euclid(PI);
499            let tr = target.rem_euclid(PI);
500            let d = (xr - tr).abs();
501            d.min(PI - d)
502        };
503        // One of the two axes matches horizontal (line 0), the other vertical (line π/2).
504        let horiz = near_line(best.axes[0].angle, 0.0).min(near_line(best.axes[1].angle, 0.0));
505        let vert =
506            near_line(best.axes[0].angle, FRAC_PI_2).min(near_line(best.axes[1].angle, FRAC_PI_2));
507        assert!(
508            horiz < 0.35,
509            "horiz line miss: {horiz}, axes {:?}",
510            best.axes
511        );
512        assert!(vert < 0.35, "vert line miss: {vert}, axes {:?}", best.axes);
513        assert!(best.contrast > 0.0);
514
515        // Brightness shift stability: both axes survive a global
516        // intensity offset.
517        let mut brighter = img.clone();
518        for p in brighter.pixels_mut() {
519            p[0] = p[0].saturating_add(5);
520        }
521
522        let brighter_corners =
523            find_corners_u8(brighter.as_raw(), size as usize, size as usize, &params);
524        assert!(!brighter_corners.is_empty());
525        let best_brighter = brighter_corners
526            .iter()
527            .max_by(|a, b| a.response.partial_cmp(&b.response).unwrap())
528            .expect("non-empty brighter");
529
530        assert!((best.x - best_brighter.x).abs() < 0.5 && (best.y - best_brighter.y).abs() < 0.5);
531
532        let da0 = near_line(best.axes[0].angle, best_brighter.axes[0].angle);
533        let da1 = near_line(best.axes[1].angle, best_brighter.axes[1].angle);
534        assert!(da0 < 0.35, "axis0 delta after brightness shift: {da0}");
535        assert!(da1 < 0.35, "axis1 delta after brightness shift: {da1}");
536    }
537
538    #[test]
539    fn default_refiner_matches_center_of_mass() {
540        let mut resp = ResponseMap {
541            w: 32,
542            h: 32,
543            data: vec![0.0; 32 * 32],
544        };
545
546        let cx = 16usize;
547        let cy = 16usize;
548        let w = resp.w;
549
550        resp.data[cy * w + cx] = 10.0;
551        resp.data[cy * w + (cx + 1)] = 6.0;
552        resp.data[(cy + 1) * w + cx] = 5.0;
553        resp.data[(cy + 1) * w + (cx + 1)] = 4.0;
554
555        let params = ChessParams {
556            threshold_rel: 0.01,
557            ..Default::default()
558        };
559
560        let mut refiner = CenterOfMassRefiner::new(CenterOfMassConfig::default());
561        let ctx = RefineContext {
562            image: None,
563            response: Some(&resp),
564        };
565        let expected = refiner.refine([cx as f32, cy as f32], ctx);
566        assert_eq!(expected.status, RefineStatus::Accepted);
567
568        let corners = detect_corners_from_response(&resp, &params);
569        assert_eq!(corners.len(), 1);
570        let c = &corners[0];
571        assert!((c.x - expected.x).abs() < 1e-6);
572        assert!((c.y - expected.y).abs() < 1e-6);
573    }
574
575    #[test]
576    fn params_refiner_controls_margin() {
577        let mut resp = ResponseMap {
578            w: 30,
579            h: 30,
580            data: vec![0.0; 30 * 30],
581        };
582
583        let cx = 10usize;
584        let cy = 10usize;
585        let w = resp.w;
586
587        resp.data[cy * w + cx] = 10.0;
588        resp.data[cy * w + (cx + 1)] = 1.0;
589        resp.data[(cy + 1) * w + cx] = 1.0;
590
591        let mut params = ChessParams {
592            threshold_abs: Some(0.5),
593            threshold_rel: 0.0,
594            ..Default::default()
595        };
596
597        let baseline = detect_corners_from_response(&resp, &params);
598        assert_eq!(baseline.len(), 1, "expected baseline detection");
599
600        params.refiner = RefinerKind::CenterOfMass(CenterOfMassConfig { radius: 4 });
601        let shrunk = detect_corners_from_response(&resp, &params);
602        assert!(
603            shrunk.is_empty(),
604            "larger refiner radius should increase border and skip the corner"
605        );
606    }
607
608    #[test]
609    fn merge_corners_prefers_stronger_entries() {
610        let mut corners = vec![
611            Corner {
612                x: 10.0,
613                y: 10.0,
614                strength: 1.0,
615            },
616            Corner {
617                x: 11.0,
618                y: 11.0,
619                strength: 5.0,
620            },
621            Corner {
622                x: 20.0,
623                y: 20.0,
624                strength: 3.0,
625            },
626        ];
627        let merged = merge_corners_simple(&mut corners, 2.5);
628        assert_eq!(merged.len(), 2);
629        assert!(merged.iter().any(|c| (c.x - 11.0).abs() < 1e-6
630            && (c.y - 11.0).abs() < 1e-6
631            && (c.strength - 5.0).abs() < 1e-6));
632        assert!(merged
633            .iter()
634            .any(|c| (c.x - 20.0).abs() < 1e-6 && (c.y - 20.0).abs() < 1e-6));
635    }
636
637    /// Cheap LCG so we can run a deterministic randomized equivalence
638    /// test without pulling in `rand` as a dev-dep.
639    fn lcg(state: &mut u64) -> u32 {
640        *state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
641        (*state >> 33) as u32
642    }
643
644    fn rand_corners(seed: u64, n: usize, span: f32) -> Vec<Corner> {
645        let mut state = seed.wrapping_add(0x9E3779B97F4A7C15);
646        let mut out = Vec::with_capacity(n);
647        for _ in 0..n {
648            let rx = lcg(&mut state) as f32 / u32::MAX as f32;
649            let ry = lcg(&mut state) as f32 / u32::MAX as f32;
650            let rs = lcg(&mut state) as f32 / u32::MAX as f32;
651            out.push(Corner {
652                x: rx * span,
653                y: ry * span,
654                strength: rs * 100.0,
655            });
656        }
657        out
658    }
659
660    fn corners_eq_unordered(a: &[Corner], b: &[Corner]) -> bool {
661        if a.len() != b.len() {
662            return false;
663        }
664        let mut used = vec![false; b.len()];
665        'outer: for ca in a.iter() {
666            for (i, cb) in b.iter().enumerate() {
667                if used[i] {
668                    continue;
669                }
670                if (ca.x - cb.x).abs() < 1e-5
671                    && (ca.y - cb.y).abs() < 1e-5
672                    && (ca.strength - cb.strength).abs() < 1e-3
673                {
674                    used[i] = true;
675                    continue 'outer;
676                }
677            }
678            return false;
679        }
680        true
681    }
682
683    #[test]
684    fn merge_corners_grid_matches_naive_scan() {
685        // Equivalence check: the spatial-grid `merge_corners_simple`
686        // must produce the same kept-corner set as the naive O(N²)
687        // pairwise scan on randomized inputs. Order is allowed to
688        // differ — both implementations are deterministic but the
689        // grid visits cells in a different order.
690        for seed in [1u64, 7, 42, 123, 1729] {
691            for &n in &[16usize, 64, 256, 1024] {
692                for &radius in &[1.0f32, 2.5, 6.0] {
693                    let corners = rand_corners(seed, n, 50.0);
694                    let mut a = corners.clone();
695                    let mut b = corners.clone();
696                    let r2 = radius * radius;
697                    let merged_grid = merge_corners_simple(&mut a, radius);
698                    let merged_naive = merge_corners_naive(&mut b, r2);
699                    assert!(
700                        corners_eq_unordered(&merged_grid, &merged_naive),
701                        "mismatch at seed={seed}, n={n}, radius={radius}: grid={} naive={}",
702                        merged_grid.len(),
703                        merged_naive.len(),
704                    );
705                }
706            }
707        }
708    }
709
710    #[test]
711    fn merge_corners_handles_empty_and_zero_radius() {
712        let mut empty: Vec<Corner> = Vec::new();
713        let merged = merge_corners_simple(&mut empty, 1.0);
714        assert!(merged.is_empty());
715
716        let mut few = vec![
717            Corner {
718                x: 1.0,
719                y: 1.0,
720                strength: 1.0,
721            },
722            Corner {
723                x: 1.5,
724                y: 1.5,
725                strength: 2.0,
726            },
727        ];
728        // Zero radius is a no-op merge — every input survives.
729        let merged = merge_corners_simple(&mut few, 0.0);
730        assert_eq!(merged.len(), 2);
731    }
732}