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//! 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}