Skip to main content

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