Skip to main content

projective_grid/shared/extension/
mod.rs

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