Skip to main content

projective_grid/square/extension/
mod.rs

1//! Boundary extension via fitted homography (Stage 6).
2//!
3//! Two strategies are available:
4//!
5//! - [`extend_via_global_homography`] — fits a single global H over the
6//!   entire labelled set. Cheap and simple, but the residual gate
7//!   refuses extrapolation under heavy radial distortion or
8//!   multi-region perspective (where one global H cannot fit
9//!   simultaneously). The labelled set must also be large enough for
10//!   the global fit to dominate boundary noise.
11//!
12//! - [`extend_via_local_homography`] — fits a *per-candidate* H from
13//!   the K nearest labelled corners (by grid distance). Each cell gets
14//!   a local model that adapts to the local distortion regime; the
15//!   per-candidate trust gate replaces the all-or-nothing global gate.
16//!   Closer to APAP / moving-DLT in spirit. More compute (one DLT per
17//!   candidate cell), but materially better recall on extreme-angle
18//!   inputs and frames where a single H doesn't fit.
19//!
20//! Callers pick a strategy based on the expected input. The chessboard
21//! detector uses `DetectorParams::stage6_local_h` to flip between them.
22//!
23//! | Submodule | Responsibility |
24//! |---|---|
25//! | `common` (private) | Shared per-cell attachment ladder |
26//! | [`global`] | [`extend_via_global_homography`] + cell enumeration |
27//! | [`local`] | [`extend_via_local_homography`] + deep cell enumeration + K-NN |
28//!
29//! # Precision contract
30//!
31//! Stage 6 attachments must obey the same invariants as BFS attachments
32//! (zero false-positive labels). Three layers of defence:
33//!
34//! 1. **Reprojection-residual gate.** Median and worst-case residual of
35//!    `|H · (i, j) − pos(label)|` are measured on the labelled set; if
36//!    either exceeds the configured thresholds (× `cell_size`), Stage 6
37//!    refuses to extrapolate.
38//!
39//! 2. **Same per-corner gates as BFS.** Candidate filtering uses the
40//!    validator's `is_eligible` + `label_of` against `required_label_at`
41//!    (parity), `accept_candidate` (axis-cluster match), AND `edge_ok`
42//!    against at least one already-labelled cardinal neighbour.
43//!
44//! 3. **Single-claim guarantee.** Each attachment updates `by_corner`
45//!    immediately, so a corner index can only be claimed by one cell.
46
47mod common;
48pub mod global;
49pub mod local;
50
51pub use global::extend_via_global_homography;
52pub use local::extend_via_local_homography;
53
54use crate::homography::HomographyQuality;
55
56/// Parameters shared between [`ExtensionParams`] (global-H Stage 6)
57/// and [`LocalExtensionParams`] (local-H Stage 6).
58///
59/// Factoring these into a single struct ensures that tuning one
60/// strategy's common knobs and then switching strategies doesn't
61/// silently revert the change.
62#[non_exhaustive]
63#[derive(Clone, Copy, Debug)]
64pub struct ExtensionCommonParams {
65    /// Search radius around each `H · (cell)` prediction, expressed as a
66    /// fraction of `cell_size`.
67    pub search_rel: f32,
68    /// Ambiguity gate: when the second-nearest candidate is within
69    /// `factor × nearest`, the attachment is skipped. Tighter than
70    /// BFS's 1.5 because boundary errors are unrecoverable.
71    pub ambiguity_factor: f32,
72    /// Per-pass cap on iterations.
73    pub max_iters: u32,
74    /// Maximum allowed worst-case reprojection residual on the labelled
75    /// support set, expressed as a fraction of `cell_size`. For global H
76    /// this gates the whole pass; for local H it gates each candidate.
77    pub max_residual_rel: f32,
78}
79
80impl Default for ExtensionCommonParams {
81    fn default() -> Self {
82        Self {
83            search_rel: 0.40,
84            ambiguity_factor: 2.5,
85            max_iters: 5,
86            max_residual_rel: 0.30,
87        }
88    }
89}
90
91/// Tuning knobs for [`extend_via_global_homography`].
92#[non_exhaustive]
93#[derive(Clone, Copy, Debug)]
94pub struct ExtensionParams {
95    /// Shared knobs — search radius, ambiguity, iteration cap, residual
96    /// gate. See [`ExtensionCommonParams`].
97    pub common: ExtensionCommonParams,
98    /// Minimum labelled count below which we refuse to fit a global H.
99    /// 12 is enough for an over-determined 9-DOF DLT (3× over) on a
100    /// non-degenerate quad layout.
101    pub min_labels_for_h: usize,
102    /// Maximum allowed *median* reprojection residual on the labelled
103    /// set, expressed as a fraction of `cell_size`.
104    pub max_median_residual_rel: f32,
105}
106
107impl Default for ExtensionParams {
108    fn default() -> Self {
109        Self {
110            common: ExtensionCommonParams::default(),
111            min_labels_for_h: 12,
112            max_median_residual_rel: 0.10,
113        }
114    }
115}
116
117/// Tuning knobs for [`extend_via_local_homography`].
118#[non_exhaustive]
119#[derive(Clone, Copy, Debug)]
120pub struct LocalExtensionParams {
121    /// Shared knobs — search radius, ambiguity, iteration cap, residual
122    /// gate. See [`ExtensionCommonParams`].
123    ///
124    /// Note: for local-H the default `max_iters` is 8 (vs 5 for global-H)
125    /// because local-H typically needs more passes to propagate outward.
126    pub common: ExtensionCommonParams,
127    /// Number of nearest labelled corners (by grid Manhattan distance)
128    /// used to fit each candidate cell's local H.
129    pub k_nearest: usize,
130    /// Minimum supports below which a candidate cell is skipped (the
131    /// local H would be under-determined or noise-dominated). Must be
132    /// `≥ 4` for DLT to be solvable.
133    pub min_k: usize,
134    /// Cell distance past the current bbox to enumerate per iter.
135    /// `1` is the original behaviour (extend by one cell, iterate).
136    /// Larger values let one iter reach further when the immediate
137    /// neighbour cells are empty but cells further out have corners.
138    pub extend_depth: u32,
139}
140
141impl Default for LocalExtensionParams {
142    fn default() -> Self {
143        Self {
144            common: ExtensionCommonParams {
145                max_iters: 8, // local-H default differs from global-H
146                ..ExtensionCommonParams::default()
147            },
148            k_nearest: 12,
149            min_k: 6,
150            extend_depth: 3,
151        }
152    }
153}
154
155/// Diagnostic counters returned by both extension strategies.
156///
157/// `attached_indices` lets callers identify Stage-6 attachments distinct
158/// from Stage-5 BFS labels, e.g., for downstream blacklist scoping or
159/// overlay rendering.
160#[non_exhaustive]
161#[derive(Clone, Debug, Default)]
162pub struct ExtensionStats {
163    pub iterations: usize,
164    /// `None` when the H wasn't fit (too few labels or solver failure).
165    pub h_quality: Option<HomographyQuality<f32>>,
166    /// `None` when the H wasn't fit. Pixel units.
167    pub h_residual_median_px: Option<f32>,
168    pub h_residual_max_px: Option<f32>,
169    /// `false` when the residual gate refused to extrapolate — the
170    /// function is a no-op and `attached == 0`.
171    pub h_trusted: bool,
172    pub attached: usize,
173    pub rejected_no_candidate: usize,
174    pub rejected_ambiguous: usize,
175    pub rejected_label: usize,
176    pub rejected_validator: usize,
177    pub rejected_edge: usize,
178    /// Indices of the corners attached in this pass.
179    pub attached_indices: Vec<usize>,
180    /// `(i, j)` cells that survived to attachment.
181    pub attached_cells: Vec<(i32, i32)>,
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::square::grow::{Admit, GrowResult, GrowValidator, LabelledNeighbour};
188    use nalgebra::Point2;
189    use std::collections::HashMap;
190
191    /// Trivial validator: every corner eligible, no parity, accept every candidate.
192    struct OpenValidator;
193
194    impl GrowValidator for OpenValidator {
195        fn is_eligible(&self, _idx: usize) -> bool {
196            true
197        }
198        fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
199            None
200        }
201        fn label_of(&self, _idx: usize) -> Option<u8> {
202            None
203        }
204        fn accept_candidate(
205            &self,
206            _idx: usize,
207            _at: (i32, i32),
208            _prediction: Point2<f32>,
209            _neighbours: &[LabelledNeighbour],
210        ) -> Admit {
211            Admit::Accept
212        }
213    }
214
215    /// Parity-aware validator: enforces a (i+j) % 2 == 0 → label 0,
216    /// otherwise label 1 contract.
217    struct ParityValidator {
218        labels: Vec<u8>,
219    }
220
221    impl GrowValidator for ParityValidator {
222        fn is_eligible(&self, _idx: usize) -> bool {
223            true
224        }
225        fn required_label_at(&self, i: i32, j: i32) -> Option<u8> {
226            Some(((i + j).rem_euclid(2)) as u8)
227        }
228        fn label_of(&self, idx: usize) -> Option<u8> {
229            self.labels.get(idx).copied()
230        }
231        fn accept_candidate(
232            &self,
233            _idx: usize,
234            _at: (i32, i32),
235            _prediction: Point2<f32>,
236            _neighbours: &[LabelledNeighbour],
237        ) -> Admit {
238            Admit::Accept
239        }
240    }
241
242    /// Edge-aware validator: every edge involving `forbid_idx` is bad.
243    struct EdgeRejectingValidator {
244        forbid_idx: usize,
245    }
246
247    impl GrowValidator for EdgeRejectingValidator {
248        fn is_eligible(&self, _idx: usize) -> bool {
249            true
250        }
251        fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
252            None
253        }
254        fn label_of(&self, _idx: usize) -> Option<u8> {
255            None
256        }
257        fn accept_candidate(
258            &self,
259            _idx: usize,
260            _at: (i32, i32),
261            _prediction: Point2<f32>,
262            _neighbours: &[LabelledNeighbour],
263        ) -> Admit {
264            Admit::Accept
265        }
266        fn edge_ok(
267            &self,
268            candidate_idx: usize,
269            neighbour_idx: usize,
270            _at_candidate: (i32, i32),
271            _at_neighbour: (i32, i32),
272        ) -> bool {
273            candidate_idx != self.forbid_idx && neighbour_idx != self.forbid_idx
274        }
275    }
276
277    fn synthetic_grid(rows: i32, cols: i32, scale: f32) -> Vec<Point2<f32>> {
278        let mut pts = Vec::with_capacity((rows * cols) as usize);
279        for j in 0..rows {
280            for i in 0..cols {
281                pts.push(Point2::new(
282                    i as f32 * scale + 100.0,
283                    j as f32 * scale + 50.0,
284                ));
285            }
286        }
287        pts
288    }
289
290    fn label_subgrid(
291        positions: &[Point2<f32>],
292        cols: i32,
293        i_range: std::ops::Range<i32>,
294        j_range: std::ops::Range<i32>,
295    ) -> GrowResult {
296        let mut labelled = HashMap::new();
297        let mut by_corner = HashMap::new();
298        for j in j_range {
299            for i in i_range.clone() {
300                let idx = (j * cols + i) as usize;
301                labelled.insert((i, j), idx);
302                by_corner.insert(idx, (i, j));
303            }
304        }
305        let _ = positions;
306        GrowResult {
307            labelled,
308            by_corner,
309            ..Default::default()
310        }
311    }
312
313    #[test]
314    fn extends_clean_perspective_grid() {
315        let cols = 6_i32;
316        let rows = 4_i32;
317        let scale = 50.0_f32;
318        let positions = synthetic_grid(rows, cols, scale);
319        let mut grow = label_subgrid(&positions, cols, 1..5, 1..3);
320        let starting_count = grow.labelled.len();
321        assert_eq!(starting_count, 8);
322
323        let stats = extend_via_global_homography(
324            &positions,
325            &mut grow,
326            scale,
327            &ExtensionParams {
328                min_labels_for_h: 4,
329                ..Default::default()
330            },
331            &OpenValidator,
332        );
333
334        assert!(stats.h_trusted, "H must be trusted on a clean affine grid");
335        assert!(
336            grow.labelled.len() > starting_count,
337            "extension should add corners on a clean grid"
338        );
339    }
340
341    #[test]
342    fn refuses_to_extend_when_residuals_too_high() {
343        let cols = 4_i32;
344        let rows = 4_i32;
345        let scale = 50.0_f32;
346        let mut positions = synthetic_grid(rows, cols, scale);
347        positions[(cols + 1) as usize].x += scale * 0.5;
348        let mut grow = label_subgrid(&positions, cols, 0..4, 0..4);
349
350        let stats = extend_via_global_homography(
351            &positions,
352            &mut grow,
353            scale,
354            &ExtensionParams {
355                min_labels_for_h: 4,
356                common: ExtensionCommonParams {
357                    max_residual_rel: 0.30,
358                    ..ExtensionCommonParams::default()
359                },
360                ..Default::default()
361            },
362            &OpenValidator,
363        );
364        assert!(!stats.h_trusted);
365        assert_eq!(stats.attached, 0);
366    }
367
368    #[test]
369    fn no_op_when_too_few_labels() {
370        let cols = 4_i32;
371        let rows = 4_i32;
372        let positions = synthetic_grid(rows, cols, 50.0);
373        let mut grow = label_subgrid(&positions, cols, 0..2, 0..2);
374        let stats = extend_via_global_homography(
375            &positions,
376            &mut grow,
377            50.0,
378            &ExtensionParams::default(),
379            &OpenValidator,
380        );
381        assert_eq!(stats.attached, 0);
382        assert!(stats.h_quality.is_none());
383    }
384
385    #[test]
386    fn rejects_wrong_parity_corner_at_h_prediction() {
387        let cols = 4_i32;
388        let rows = 4_i32;
389        let scale = 50.0_f32;
390        let positions = synthetic_grid(rows, cols, scale);
391        let mut grow = label_subgrid(&positions, cols, 1..3, 1..3);
392        let labels: Vec<u8> = (0..(rows * cols))
393            .map(|k| {
394                let i = k % cols;
395                let j = k / cols;
396                ((i + j).rem_euclid(2)) as u8
397            })
398            .collect();
399        let bad_idx = cols as usize;
400        let mut labels = labels;
401        labels[bad_idx] = 0;
402        let validator = ParityValidator { labels };
403
404        let stats = extend_via_global_homography(
405            &positions,
406            &mut grow,
407            scale,
408            &ExtensionParams {
409                min_labels_for_h: 4,
410                ..Default::default()
411            },
412            &validator,
413        );
414        assert!(stats.h_trusted);
415        assert!(!grow.labelled.contains_key(&(0, 1)) || grow.labelled[&(0, 1)] != bad_idx);
416        assert!(stats.rejected_label >= 1);
417    }
418
419    #[test]
420    fn rejects_bad_edge_via_edge_ok_gate() {
421        let cols = 4_i32;
422        let rows = 4_i32;
423        let scale = 50.0_f32;
424        let positions = synthetic_grid(rows, cols, scale);
425        let mut grow = label_subgrid(&positions, cols, 1..3, 1..3);
426
427        let bad_candidate = cols as usize;
428        let validator = EdgeRejectingValidator {
429            forbid_idx: bad_candidate,
430        };
431
432        let stats = extend_via_global_homography(
433            &positions,
434            &mut grow,
435            scale,
436            &ExtensionParams {
437                min_labels_for_h: 4,
438                ..Default::default()
439            },
440            &validator,
441        );
442        assert!(stats.h_trusted);
443        assert!(stats.rejected_edge >= 1);
444        assert!(!grow.labelled.contains_key(&(0, 1)));
445    }
446
447    #[test]
448    fn single_claim_prevents_double_attach() {
449        let scale = 50.0_f32;
450        let mut positions = Vec::new();
451        for j in 0..3_i32 {
452            for i in 0..3_i32 {
453                positions.push(Point2::new(
454                    i as f32 * scale + 100.0,
455                    j as f32 * scale + 50.0,
456                ));
457            }
458        }
459        positions.push(Point2::new(250.0, 125.0));
460
461        let mut labelled = HashMap::new();
462        let mut by_corner = HashMap::new();
463        for j in 0..3_i32 {
464            for i in 0..3_i32 {
465                let idx = (j * 3 + i) as usize;
466                labelled.insert((i, j), idx);
467                by_corner.insert(idx, (i, j));
468            }
469        }
470        let mut grow = GrowResult {
471            labelled,
472            by_corner,
473            ..Default::default()
474        };
475
476        let stats = extend_via_global_homography(
477            &positions,
478            &mut grow,
479            scale,
480            &ExtensionParams {
481                min_labels_for_h: 4,
482                common: ExtensionCommonParams {
483                    search_rel: 1.5,
484                    ambiguity_factor: 1.01,
485                    ..ExtensionCommonParams::default()
486                },
487                ..Default::default()
488            },
489            &OpenValidator,
490        );
491        assert!(stats.h_trusted);
492        let attached_for_idx_9: Vec<&(i32, i32)> = grow
493            .labelled
494            .iter()
495            .filter_map(|(k, &v)| if v == 9 { Some(k) } else { None })
496            .collect();
497        assert!(
498            attached_for_idx_9.len() <= 1,
499            "corner index 9 attached to {} cells: {:?}",
500            attached_for_idx_9.len(),
501            attached_for_idx_9
502        );
503        for (&cell, &idx) in &grow.labelled {
504            assert_eq!(grow.by_corner.get(&idx), Some(&cell));
505        }
506    }
507
508    // --- local-H Stage 6 tests ---
509
510    #[test]
511    fn local_h_extends_clean_perspective_grid() {
512        let cols = 6_i32;
513        let rows = 4_i32;
514        let scale = 50.0_f32;
515        let positions = synthetic_grid(rows, cols, scale);
516        let mut grow = label_subgrid(&positions, cols, 1..5, 1..3);
517        let starting_count = grow.labelled.len();
518        assert_eq!(starting_count, 8);
519
520        let stats = extend_via_local_homography(
521            &positions,
522            &mut grow,
523            scale,
524            &LocalExtensionParams {
525                min_k: 4,
526                k_nearest: 8,
527                ..Default::default()
528            },
529            &OpenValidator,
530        );
531
532        assert!(stats.h_trusted);
533        assert!(
534            grow.labelled.len() > starting_count,
535            "local-H extension should add corners on a clean grid"
536        );
537    }
538
539    #[test]
540    fn local_h_reaches_further_than_global() {
541        let cols = 8_i32;
542        let rows = 4_i32;
543        let scale = 50.0_f32;
544        let positions = synthetic_grid(rows, cols, scale);
545        let mut grow_local = label_subgrid(&positions, cols, 2..6, 0..rows);
546        assert_eq!(grow_local.labelled.len(), 16);
547
548        let stats = extend_via_local_homography(
549            &positions,
550            &mut grow_local,
551            scale,
552            &LocalExtensionParams {
553                min_k: 4,
554                ..Default::default()
555            },
556            &OpenValidator,
557        );
558
559        assert!(stats.iterations >= 2, "expected >= 2 iters");
560        assert_eq!(
561            grow_local.labelled.len(),
562            (rows * cols) as usize,
563            "local-H should reach every cell on a clean grid: {} of {}",
564            grow_local.labelled.len(),
565            rows * cols,
566        );
567    }
568
569    #[test]
570    fn local_h_no_op_when_too_few_labels() {
571        let cols = 4_i32;
572        let rows = 4_i32;
573        let positions = synthetic_grid(rows, cols, 50.0);
574        let mut grow = label_subgrid(&positions, cols, 0..2, 0..2);
575        let stats = extend_via_local_homography(
576            &positions,
577            &mut grow,
578            50.0,
579            &LocalExtensionParams {
580                min_k: 8,
581                ..Default::default()
582            },
583            &OpenValidator,
584        );
585        assert_eq!(stats.attached, 0);
586        assert!(!stats.h_trusted);
587    }
588
589    #[test]
590    fn local_h_rejects_wrong_parity() {
591        let cols = 4_i32;
592        let rows = 4_i32;
593        let scale = 50.0_f32;
594        let positions = synthetic_grid(rows, cols, scale);
595        let mut grow = label_subgrid(&positions, cols, 1..3, 1..3);
596        let labels: Vec<u8> = (0..(rows * cols))
597            .map(|k| {
598                let i = k % cols;
599                let j = k / cols;
600                ((i + j).rem_euclid(2)) as u8
601            })
602            .collect();
603        let bad_idx = cols as usize;
604        let mut labels = labels;
605        labels[bad_idx] = 0;
606        let validator = ParityValidator { labels };
607
608        let stats = extend_via_local_homography(
609            &positions,
610            &mut grow,
611            scale,
612            &LocalExtensionParams {
613                min_k: 4,
614                ..Default::default()
615            },
616            &validator,
617        );
618        assert!(!grow.labelled.contains_key(&(0, 1)) || grow.labelled[&(0, 1)] != bad_idx);
619        assert!(stats.rejected_label >= 1);
620    }
621}