projective_grid/component_merge.rs
1//! Local-geometry-only component merge for square grids.
2//!
3//! Both the topological pipeline ([`crate::topological`]) and the
4//! chessboard-v2 seed-and-grow pipeline can leave multiple disconnected
5//! grid components when a board is partially occluded, when a line of
6//! ChESS corners drops below the strength threshold, or when topological
7//! filtering removes a noisy quad in the middle of the board. This
8//! module attempts to reunite components in label space.
9//!
10//! # Acceptance criterion
11//!
12//! Local geometry only — never a global homography fit. Strong radial
13//! distortion can break a single global homography across the whole
14//! board, so we score component pairs purely from agreement between
15//! corners that should coincide after a candidate alignment:
16//!
17//! - **Per-component cell size** (median nearest-neighbour distance
18//! along the component's `i` and `j` axes) must agree within
19//! `cell_size_ratio_tol`.
20//! - **Per-corner positions** of overlapping labels must agree within
21//! `position_tol_rel * mean_cell_size` pixels.
22//! - **Overlap count** must reach `min_overlap`.
23//!
24//! Component reorientation uses the eight elements of D4
25//! ([`crate::GRID_TRANSFORMS_D4`]). The translation is fixed by an
26//! anchor-pair correspondence; we try every anchor pair from each
27//! component to find the best alignment.
28//!
29//! # Out-of-scope (v1)
30//!
31//! Disjoint label sets with no overlap. Such pairs are common when an
32//! entire row of corners is missing. The current implementation rejects
33//! them; extend by adding a "predict-next-corner" check that compares
34//! one component's predicted boundary position to the other's actual
35//! boundary corner.
36
37use std::collections::HashMap;
38
39use kiddo::{KdTree, SquaredEuclidean};
40use nalgebra::Point2;
41use serde::{Deserialize, Serialize};
42
43use crate::square::alignment::GridTransform;
44
45/// Tuning knobs for [`merge_components_local`].
46#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
47#[non_exhaustive]
48pub struct LocalMergeParams {
49 /// Position tolerance for accepting two corners as the same physical
50 /// point, expressed as a fraction of the mean per-component cell
51 /// size in pixels. Default: `0.20`.
52 pub position_tol_rel: f32,
53 /// Cell-size agreement tolerance: `|s_p - s_q| / max(s_p, s_q)` must
54 /// be ≤ this value to even attempt a merge. Default: `0.20`.
55 pub cell_size_ratio_tol: f32,
56 /// Minimum number of overlapping labels (after candidate alignment)
57 /// for a merge to be accepted. Default: `2`.
58 pub min_overlap: usize,
59 /// Upper bound on returned components after merging. Default: `4`.
60 pub max_components: usize,
61}
62
63impl Default for LocalMergeParams {
64 fn default() -> Self {
65 Self {
66 position_tol_rel: 0.20,
67 cell_size_ratio_tol: 0.20,
68 min_overlap: 2,
69 max_components: 4,
70 }
71 }
72}
73
74/// Slim view over one component's data for merging.
75#[derive(Clone, Copy, Debug)]
76pub struct ComponentInput<'a> {
77 /// `(i, j) → corner_idx` (indices into `positions`).
78 pub labelled: &'a HashMap<(i32, i32), usize>,
79 pub positions: &'a [Point2<f32>],
80}
81
82/// Output of [`merge_components_local`].
83#[derive(Clone, Debug, Default)]
84pub struct ComponentMergeResult {
85 /// One labelling per surviving component. Each is rebased to start
86 /// at `(0, 0)`. Corners in the input may appear in multiple
87 /// components if alignment was ambiguous.
88 pub components: Vec<HashMap<(i32, i32), usize>>,
89 pub diagnostics: ComponentMergeStats,
90}
91
92/// Diagnostics for a single merge call.
93#[derive(Clone, Copy, Debug, Default)]
94#[non_exhaustive]
95pub struct ComponentMergeStats {
96 pub components_in: usize,
97 pub components_out: usize,
98 pub merges_accepted: usize,
99}
100
101fn euclidean(p: Point2<f32>, q: Point2<f32>) -> f32 {
102 ((p.x - q.x).powi(2) + (p.y - q.y).powi(2)).sqrt()
103}
104
105/// Median nearest-neighbour cell size along grid axes (i and j directions).
106/// Falls back to 0.0 if the component has fewer than two corners.
107fn estimate_cell_size(c: &ComponentInput<'_>) -> f32 {
108 let mut dists: Vec<f32> = Vec::new();
109 for (&(i, j), &idx) in c.labelled.iter() {
110 let p = c.positions[idx];
111 if let Some(&right) = c.labelled.get(&(i + 1, j)) {
112 dists.push(euclidean(p, c.positions[right]));
113 }
114 if let Some(&down) = c.labelled.get(&(i, j + 1)) {
115 dists.push(euclidean(p, c.positions[down]));
116 }
117 }
118 if dists.is_empty() {
119 return 0.0;
120 }
121 dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
122 dists[dists.len() / 2]
123}
124
125/// Apply D4 transform to label coordinates.
126#[inline]
127fn apply_transform(t: GridTransform, ij: (i32, i32)) -> (i32, i32) {
128 let v = t.apply(ij.0, ij.1);
129 (v.i, v.j)
130}
131
132/// For a candidate `(transform, delta)`, score the alignment by full
133/// label-space overlap.
134///
135/// Counts every `c_p` label whose `transform · ij_p + delta` exists as a
136/// key in `c_q.labelled` (regardless of pixel distance), and tracks the
137/// worst pixel-position disagreement among those overlapping label
138/// pairs. The histogram-based candidate enumeration in
139/// [`find_best_alignment`] only sees pairs already within `pos_tol`, so
140/// without this re-scoring an alignment whose label-space overlap
141/// includes one or more pairs *outside* `pos_tol` would silently merge.
142/// That would corrupt downstream calibration. Use this re-scoring as
143/// the precision gate before accepting a candidate.
144fn score_alignment(
145 c_p: &ComponentInput<'_>,
146 c_q: &ComponentInput<'_>,
147 t: GridTransform,
148 delta: (i32, i32),
149) -> (usize, f32) {
150 let mut overlap = 0usize;
151 let mut max_err = 0.0f32;
152 for (&ij_p, &idx_p) in c_p.labelled.iter() {
153 let ij_t = apply_transform(t, ij_p);
154 let ij_q = (ij_t.0 + delta.0, ij_t.1 + delta.1);
155 if let Some(&idx_q) = c_q.labelled.get(&ij_q) {
156 let err = euclidean(c_p.positions[idx_p], c_q.positions[idx_q]);
157 overlap += 1;
158 if err > max_err {
159 max_err = err;
160 }
161 }
162 }
163 (overlap, max_err)
164}
165
166/// Find the best (transform, offset) for merging `c_p` into `c_q`'s frame.
167///
168/// Two-pass strategy:
169///
170/// 1. **Hough enumeration.** Index `c_q`'s positions in a KD-tree, then
171/// for each label in `c_p` find every `c_q` label whose pixel
172/// position is within `pos_tol` and vote each match into a histogram
173/// bin keyed by the candidate `(transform, label-delta)`. This
174/// surfaces a small set of candidate alignments in `O(P log Q)`,
175/// replacing the previous `O(P² Q)` anchor enumeration.
176/// 2. **Full-overlap re-scoring.** Each surviving candidate is
177/// re-scored by [`score_alignment`] over the *full* label-space
178/// overlap (every `c_p` label whose `transform · ij_p + delta` is a
179/// key in `c_q.labelled`, regardless of pixel distance). The
180/// candidate is accepted only when the re-scored overlap meets
181/// `min_overlap` AND the re-scored `max_err` is within `pos_tol`.
182/// This is the precision gate: a histogram bin can pass with
183/// `min_overlap` position-close inliers even when other label-space
184/// overlaps under the same alignment sit far above tolerance, and
185/// accepting such an alignment would corrupt downstream calibration.
186/// Re-scoring catches that case.
187///
188/// The accepted candidate set is then ranked by
189/// `(overlap_full desc, max_err_full asc, transform_index asc,
190/// delta asc)` — a strict total order that matches the original
191/// algorithm's tiebreaker (which preferred identity by D4 iteration
192/// order).
193fn find_best_alignment(
194 c_p: &ComponentInput<'_>,
195 c_q: &ComponentInput<'_>,
196 cell_size: f32,
197 params: &LocalMergeParams,
198) -> Option<(GridTransform, (i32, i32), usize)> {
199 let pos_tol = params.position_tol_rel * cell_size.max(1.0);
200 let pos_tol_sq = pos_tol * pos_tol;
201
202 // KD-tree over c_q label positions. The slot index maps back to
203 // q_entries[slot] = (ij_q, idx_q).
204 let q_entries: Vec<((i32, i32), usize)> = c_q.labelled.iter().map(|(k, v)| (*k, *v)).collect();
205 if q_entries.is_empty() {
206 return None;
207 }
208 let mut tree: KdTree<f32, 2> = KdTree::new();
209 for (slot, (_, idx)) in q_entries.iter().enumerate() {
210 let pos = c_q.positions[*idx];
211 tree.add(&[pos.x, pos.y], slot as u64);
212 }
213
214 // Pass 1: Hough enumeration. The bin counts position-close votes
215 // only — that's a *lower bound* on the full label-space overlap.
216 let mut hist: HashMap<(u8, i32, i32), usize> = HashMap::new();
217 for (&ij_p, &idx_p) in c_p.labelled.iter() {
218 let pos_p = c_p.positions[idx_p];
219 for nn in tree
220 .within_unsorted::<SquaredEuclidean>(&[pos_p.x, pos_p.y], pos_tol_sq)
221 .into_iter()
222 {
223 let slot = nn.item as usize;
224 let (ij_q, _idx_q) = q_entries[slot];
225 for (t_idx, t) in crate::GRID_TRANSFORMS_D4.iter().enumerate() {
226 let tij_p = apply_transform(*t, ij_p);
227 let key = (t_idx as u8, ij_q.0 - tij_p.0, ij_q.1 - tij_p.1);
228 *hist.entry(key).or_insert(0usize) += 1;
229 }
230 }
231 }
232
233 // Pass 2: re-score each candidate over the full label-space
234 // overlap. A bin survives only when every `c_p` label that maps
235 // (under this t/δ) to a key in `c_q.labelled` is within `pos_tol`
236 // — see `score_alignment` for the precision contract.
237 //
238 // Tiebreaker: prefer higher overlap, then lower max_err, then
239 // smaller transform index (identity = 0, so identity wins ties),
240 // then lexicographic delta — matching the original algorithm's
241 // iteration order on highly symmetric synthetic test grids.
242 let mut best: Option<(u8, (i32, i32), usize, f32)> = None;
243 for (&(t_idx, di, dj), &kdtree_overlap) in &hist {
244 if kdtree_overlap < params.min_overlap {
245 // Histogram is a lower bound on the full overlap, but only
246 // for pairs already within `pos_tol`. A bin that fails the
247 // KD-tree-overlap floor cannot reach `min_overlap`
248 // position-close pairs and is rejected outright; we don't
249 // even bother re-scoring.
250 continue;
251 }
252 let t = crate::GRID_TRANSFORMS_D4[t_idx as usize];
253 let delta = (di, dj);
254 let (overlap_full, max_err_full) = score_alignment(c_p, c_q, t, delta);
255 if overlap_full < params.min_overlap || max_err_full > pos_tol {
256 continue;
257 }
258 let take = match &best {
259 None => true,
260 Some((best_t_idx, best_delta, best_overlap, best_err)) => {
261 if overlap_full != *best_overlap {
262 overlap_full > *best_overlap
263 } else if (max_err_full - *best_err).abs() > f32::EPSILON {
264 max_err_full < *best_err
265 } else if t_idx != *best_t_idx {
266 t_idx < *best_t_idx
267 } else {
268 (di, dj) < *best_delta
269 }
270 }
271 };
272 if take {
273 best = Some((t_idx, (di, dj), overlap_full, max_err_full));
274 }
275 }
276 best.map(|(t_idx, d, n, _)| (crate::GRID_TRANSFORMS_D4[t_idx as usize], d, n))
277}
278
279fn rebase(labelled: &mut HashMap<(i32, i32), usize>) {
280 if labelled.is_empty() {
281 return;
282 }
283 let min_i = labelled.keys().map(|(i, _)| *i).min().unwrap();
284 let min_j = labelled.keys().map(|(_, j)| *j).min().unwrap();
285 if min_i == 0 && min_j == 0 {
286 return;
287 }
288 let rebased: HashMap<(i32, i32), usize> = labelled
289 .drain()
290 .map(|((i, j), v)| ((i - min_i, j - min_j), v))
291 .collect();
292 *labelled = rebased;
293}
294
295/// Greedy local merge.
296///
297/// Strategy: estimate each component's cell size, then for every pair
298/// `(p, q)` (largest-first by labelled count), search for an
299/// alignment that satisfies the cell-size, overlap, and position
300/// tolerances. On success, rewrite `p`'s labels into `q`'s frame and
301/// merge into `q`. Repeat until no further merges are possible or the
302/// `max_components` cap is reached.
303#[cfg_attr(
304 feature = "tracing",
305 tracing::instrument(
306 level = "info",
307 skip_all,
308 fields(num_components = inputs.len()),
309 )
310)]
311pub fn merge_components_local(
312 inputs: &[ComponentInput<'_>],
313 params: &LocalMergeParams,
314) -> ComponentMergeResult {
315 let mut stats = ComponentMergeStats {
316 components_in: inputs.len(),
317 ..Default::default()
318 };
319 if inputs.is_empty() {
320 return ComponentMergeResult {
321 components: Vec::new(),
322 diagnostics: stats,
323 };
324 }
325
326 // Working copies.
327 let mut working: Vec<HashMap<(i32, i32), usize>> =
328 inputs.iter().map(|c| c.labelled.clone()).collect();
329 let positions_per: Vec<&[Point2<f32>]> = inputs.iter().map(|c| c.positions).collect();
330 let mut cell_sizes: Vec<f32> = inputs.iter().map(estimate_cell_size).collect();
331
332 let mut alive: Vec<bool> = vec![true; inputs.len()];
333 let mut changed = true;
334 while changed {
335 changed = false;
336 // Order alive components by size descending; bigger anchors are
337 // more reliable.
338 let mut order: Vec<usize> = (0..inputs.len()).filter(|i| alive[*i]).collect();
339 order.sort_by(|a, b| working[*b].len().cmp(&working[*a].len()));
340
341 'outer: for &i in &order {
342 for &j in &order {
343 if i == j || !alive[i] || !alive[j] {
344 continue;
345 }
346 // Cell-size sanity gate.
347 let s_i = cell_sizes[i].max(1e-3);
348 let s_j = cell_sizes[j].max(1e-3);
349 let ratio = (s_i - s_j).abs() / s_i.max(s_j);
350 if ratio > params.cell_size_ratio_tol {
351 continue;
352 }
353 let cell_size = 0.5 * (s_i + s_j);
354 let c_p = ComponentInput {
355 labelled: &working[i],
356 positions: positions_per[i],
357 };
358 let c_q = ComponentInput {
359 labelled: &working[j],
360 positions: positions_per[j],
361 };
362 let Some((t, delta, _overlap)) = find_best_alignment(&c_p, &c_q, cell_size, params)
363 else {
364 continue;
365 };
366 // Merge i into j (the larger component is j by ordering).
367 // For each label in i, transform to j's frame, insert if
368 // not already present (keeping j's value on conflict).
369 for (&ij, &idx_i) in working[i].clone().iter() {
370 let tij = apply_transform(t, ij);
371 let key = (tij.0 + delta.0, tij.1 + delta.1);
372 working[j].entry(key).or_insert(idx_i);
373 }
374 alive[i] = false;
375 cell_sizes[j] = 0.5 * (cell_sizes[i] + cell_sizes[j]);
376 stats.merges_accepted += 1;
377 changed = true;
378 continue 'outer;
379 }
380 }
381 }
382
383 let mut out: Vec<HashMap<(i32, i32), usize>> = working
384 .into_iter()
385 .zip(alive.iter().copied())
386 .filter_map(|(m, a)| if a { Some(m) } else { None })
387 .collect();
388 // Sort by size desc, cap, rebase.
389 out.sort_by_key(|m| std::cmp::Reverse(m.len()));
390 out.truncate(params.max_components);
391 for m in &mut out {
392 rebase(m);
393 }
394 stats.components_out = out.len();
395 ComponentMergeResult {
396 components: out,
397 diagnostics: stats,
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 type Labels = HashMap<(i32, i32), usize>;
406 type Positions = Vec<Point2<f32>>;
407
408 fn component_5x5() -> (Labels, Positions) {
409 let mut labelled = HashMap::new();
410 let mut positions = Vec::new();
411 for j in 0..5 {
412 for i in 0..5 {
413 let idx = positions.len();
414 labelled.insert((i, j), idx);
415 positions.push(Point2::new(i as f32 * 10.0, j as f32 * 10.0));
416 }
417 }
418 (labelled, positions)
419 }
420
421 #[test]
422 fn identical_components_merge_into_one() {
423 let (l1, p1) = component_5x5();
424 let (l2, p2) = component_5x5();
425 let inputs = vec![
426 ComponentInput {
427 labelled: &l1,
428 positions: &p1,
429 },
430 ComponentInput {
431 labelled: &l2,
432 positions: &p2,
433 },
434 ];
435 let res = merge_components_local(&inputs, &LocalMergeParams::default());
436 assert_eq!(res.components.len(), 1);
437 assert_eq!(res.components[0].len(), 25);
438 assert_eq!(res.diagnostics.merges_accepted, 1);
439 }
440
441 #[test]
442 fn shifted_components_with_overlap_merge() {
443 // C1: labels (0..3, 0..5) at world (0..2, 0..4) * step
444 // C2: labels (0..3, 0..5) at world (3..5, 0..4) * step
445 // Overlap if we offset C2 by (2, 0): C1 cell (2, j) coincides with C2 cell (0, j) world-wise.
446 let step = 10.0;
447 let mut l1 = HashMap::new();
448 let mut p1 = Vec::new();
449 for j in 0..5 {
450 for i in 0..3 {
451 let idx = p1.len();
452 l1.insert((i, j), idx);
453 p1.push(Point2::new(i as f32 * step, j as f32 * step));
454 }
455 }
456 let mut l2 = HashMap::new();
457 let mut p2 = Vec::new();
458 for j in 0..5 {
459 for i in 0..3 {
460 let idx = p2.len();
461 l2.insert((i, j), idx);
462 p2.push(Point2::new((i as f32 + 2.0) * step, j as f32 * step));
463 }
464 }
465 let inputs = vec![
466 ComponentInput {
467 labelled: &l1,
468 positions: &p1,
469 },
470 ComponentInput {
471 labelled: &l2,
472 positions: &p2,
473 },
474 ];
475 let res = merge_components_local(&inputs, &LocalMergeParams::default());
476 assert_eq!(res.components.len(), 1);
477 // Combined unique labels: (0..5, 0..5) = 25.
478 assert_eq!(res.components[0].len(), 25);
479 }
480
481 #[test]
482 fn cell_size_mismatch_blocks_merge() {
483 let (l1, p1) = component_5x5();
484 // Same labels but positions stretched 2x — cell size differs by 2x.
485 let mut l2 = HashMap::new();
486 let mut p2 = Vec::new();
487 for j in 0..5 {
488 for i in 0..5 {
489 let idx = p2.len();
490 l2.insert((i, j), idx);
491 p2.push(Point2::new(i as f32 * 20.0, j as f32 * 20.0));
492 }
493 }
494 let inputs = vec![
495 ComponentInput {
496 labelled: &l1,
497 positions: &p1,
498 },
499 ComponentInput {
500 labelled: &l2,
501 positions: &p2,
502 },
503 ];
504 let res = merge_components_local(&inputs, &LocalMergeParams::default());
505 assert_eq!(res.components.len(), 2);
506 assert_eq!(res.diagnostics.merges_accepted, 0);
507 }
508
509 /// Regression for the precision contract: a histogram bin can pass
510 /// `min_overlap` on position-close votes alone while another
511 /// label-aligned pair under the same `(transform, delta)` sits far
512 /// outside `pos_tol`. Without the full-overlap re-score, the merge
513 /// would proceed and corrupt the grid labelling.
514 ///
515 /// Setup: two 2×2 components share three corners exactly, but one
516 /// corner has drifted ~5× the cell size in `c_q`. The histogram
517 /// counts three position-close votes for `(identity, (0, 0))` —
518 /// enough to clear `min_overlap = 2`. The full label-space
519 /// overlap is four with `max_err ≈ 56 px`, which the precision
520 /// gate must reject.
521 #[test]
522 fn drifted_overlapping_corner_blocks_merge() {
523 let cell = 10.0_f32;
524 // C1: 4 labels on the unit cell, exact positions.
525 let mut l1: Labels = HashMap::new();
526 let mut p1: Positions = Vec::new();
527 for j in 0..2 {
528 for i in 0..2 {
529 let idx = p1.len();
530 l1.insert((i, j), idx);
531 p1.push(Point2::new(i as f32 * cell, j as f32 * cell));
532 }
533 }
534 // C2: same labels, but the (1, 1) corner is drifted to (50, 50)
535 // — far outside `pos_tol = 0.20 × cell = 2.0` from c_p's (10, 10).
536 let mut l2: Labels = HashMap::new();
537 let mut p2: Positions = Vec::new();
538 for j in 0..2 {
539 for i in 0..2 {
540 let idx = p2.len();
541 l2.insert((i, j), idx);
542 let pos = if (i, j) == (1, 1) {
543 Point2::new(50.0, 50.0)
544 } else {
545 Point2::new(i as f32 * cell, j as f32 * cell)
546 };
547 p2.push(pos);
548 }
549 }
550 let inputs = vec![
551 ComponentInput {
552 labelled: &l1,
553 positions: &p1,
554 },
555 ComponentInput {
556 labelled: &l2,
557 positions: &p2,
558 },
559 ];
560 let res = merge_components_local(&inputs, &LocalMergeParams::default());
561 assert_eq!(
562 res.components.len(),
563 2,
564 "drifted corner should block the merge entirely"
565 );
566 assert_eq!(res.diagnostics.merges_accepted, 0);
567 }
568}