Skip to main content

projective_grid/square/validate/
mod.rs

1//! Post-growth validation for a labelled square grid.
2//!
3//! Two independent checks run over the labelled set:
4//!
5//! 1. **Line collinearity.** For every row (`j = const`) and column
6//!    (`i = const`) with `≥ line_min_members` labelled members, fit
7//!    a least-squares line in pixel space and flag any member whose
8//!    perpendicular residual exceeds `line_tol_rel × scale`.
9//!
10//! 2. **Local-H residual.** For every labelled corner with ≥ 4
11//!    non-collinear labelled neighbors in `(i, j)`-space, fit a 4-point
12//!    local homography from the 4 grid-closest neighbors, predict the
13//!    corner's pixel position, and measure the residual. Corners whose
14//!    residual exceeds `local_h_tol_rel × scale` are flagged.
15//!
16//! `scale` is either the caller-supplied global `cell_size` (default
17//! mode) or — when [`ValidationParams::use_step_aware`] is set — a
18//! **per-corner local step** computed from labelled grid neighbours
19//! via central or one-sided finite differences. Per-corner thresholds
20//! are anisotropic: cells in perspective-foreshortened regions get a
21//! tighter pixel tolerance proportional to their (smaller) local step;
22//! cells in radially-distorted regions get a looser one. Corners
23//! without enough labelled neighbours fall back to the global
24//! `cell_size`.
25//!
26//! Flags are combined via the attribution rules below into a
27//! blacklist of **indices into the input slice**:
28//!
29//! * A corner flagged in `≥ 2` lines is the outlier.
30//! * A corner with a *large* local-H residual (`> 2 × local_h_tol`) AND
31//!   at least one line flag is the outlier.
32//! * A corner with a local-H flag but NO line flag, where at least one
33//!   of its 4 base neighbors has `≥ 1` line flags, blames the worst-
34//!   line-flagged base instead (the base is the outlier).
35//! * When [`ValidationParams::step_deviation_thresh_rel`] is set, a
36//!   corner whose local step deviates from the labelled-set median by
37//!   more than the threshold AND has ≥ 1 line flag is also an outlier.
38//! * Otherwise (isolated local-H flag with no supporting evidence),
39//!   defer — no blacklist entry in this iteration.
40//!
41//! The caller is expected to re-run the seed/grow/validate loop after
42//! updating its blacklist.
43//!
44//! # Pattern-agnostic
45//!
46//! This module has no dependency on chessboard-specific vocabulary
47//! (parity, axis clusters, label enums). Any caller that can produce
48//! a `(corner_index, pixel_position, grid_coord)` slice can use it.
49//! Consumers that carry per-stage metadata should pre-filter to the
50//! "labelled" subset before calling.
51
52mod lines;
53mod local_h;
54mod step;
55
56use nalgebra::Point2;
57use std::collections::{HashMap, HashSet};
58
59/// Tolerances for the validation pass.
60///
61/// All spatial tolerances are expressed as ratios of either the
62/// caller-supplied global `cell_size` or — when [`use_step_aware`] is
63/// set — the per-corner local step derived from labelled grid
64/// neighbours.
65///
66/// [`use_step_aware`]: ValidationParams::use_step_aware
67#[non_exhaustive]
68#[derive(Clone, Copy, Debug)]
69pub struct ValidationParams {
70    /// Straight-line fit collinearity tolerance (fraction of the
71    /// per-corner scale).
72    pub line_tol_rel: f32,
73    /// Minimum members required to fit a line / column.
74    pub line_min_members: usize,
75    /// Local-H prediction tolerance (fraction of the per-corner
76    /// scale).
77    pub local_h_tol_rel: f32,
78    /// When `true`, line and local-H thresholds use a per-corner
79    /// local step computed from labelled grid neighbours via central
80    /// or one-sided finite differences (`(step_u + step_v) / 2`).
81    /// Corners without enough labelled neighbours fall back to the
82    /// global `cell_size`.
83    ///
84    /// Set this when the grid is non-uniform in pixel space —
85    /// perspective foreshortening, radial distortion, or rectified-
86    /// then-rasterised images. Has no effect on uniform grids.
87    pub use_step_aware: bool,
88    /// When `> 0` and [`use_step_aware`] is set, an additional flag
89    /// fires for corners whose local step deviates from the labelled-
90    /// set median by more than `step_deviation_thresh_rel` (relative).
91    /// E.g. `0.5` flags corners whose step is < 1/(1+0.5) of the
92    /// median or > (1+0.5)× the median.
93    ///
94    /// Combined with line flags via the existing attribution rules
95    /// (rule 4: step-deviation flag + ≥ 1 line flag → outlier).
96    /// Set to `0.0` to disable.
97    ///
98    /// [`use_step_aware`]: ValidationParams::use_step_aware
99    pub step_deviation_thresh_rel: f32,
100}
101
102impl Default for ValidationParams {
103    fn default() -> Self {
104        // Matches `calib_targets_chessboard::DetectorParams`'s
105        // validation defaults so the thin chessboard wrapper stays a
106        // pure forward of the same numbers. Step-aware mode is opt-in.
107        Self {
108            line_tol_rel: 0.15,
109            line_min_members: 3,
110            local_h_tol_rel: 0.20,
111            use_step_aware: false,
112            step_deviation_thresh_rel: 0.0,
113        }
114    }
115}
116
117impl ValidationParams {
118    /// Construct fully-specified core tolerances. Step-aware mode is
119    /// off by default; call [`with_step_aware`] to enable it.
120    ///
121    /// [`with_step_aware`]: ValidationParams::with_step_aware
122    pub fn new(line_tol_rel: f32, line_min_members: usize, local_h_tol_rel: f32) -> Self {
123        Self {
124            line_tol_rel,
125            line_min_members,
126            local_h_tol_rel,
127            use_step_aware: false,
128            step_deviation_thresh_rel: 0.0,
129        }
130    }
131
132    /// Enable per-corner step-aware thresholds. Pass
133    /// `deviation_thresh_rel = 0.0` for thresholds-only without the
134    /// extra step-deviation flag.
135    pub fn with_step_aware(mut self, deviation_thresh_rel: f32) -> Self {
136        self.use_step_aware = true;
137        self.step_deviation_thresh_rel = deviation_thresh_rel;
138        self
139    }
140}
141
142/// A single labelled corner fed into [`validate`]: its caller-chosen
143/// index (carried back in `ValidationResult::blacklist`), its pixel
144/// position, and its integer grid coordinate.
145///
146/// The index is opaque to this module — callers may pick any scheme
147/// (direct slice indices, corner struct fields, etc.) as long as the
148/// same scheme maps `blacklist` entries back to their originals.
149#[derive(Clone, Copy, Debug)]
150pub struct LabelledEntry {
151    /// Caller-chosen opaque index, carried back in `ValidationResult::blacklist`.
152    pub idx: usize,
153    /// The corner's position in image pixels.
154    pub pixel: Point2<f32>,
155    /// The corner's integer `(i, j)` grid coordinate.
156    pub grid: (i32, i32),
157}
158
159/// Outcome of one validation pass.
160#[derive(Debug, Default)]
161pub struct ValidationResult {
162    /// Corner indices to blacklist (attribution has been applied).
163    pub blacklist: HashSet<usize>,
164    /// For each labelled corner, its local-H residual in pixels
165    /// (`None` when fewer than 4 non-collinear neighbors were
166    /// available).
167    pub local_h_residuals: HashMap<usize, f32>,
168}
169
170/// Run both validation passes and produce a blacklist.
171#[cfg_attr(
172    feature = "tracing",
173    tracing::instrument(
174        level = "info",
175        skip_all,
176        fields(num_labelled = entries.len(), cell_size = cell_size),
177    )
178)]
179pub fn validate(
180    entries: &[LabelledEntry],
181    cell_size: f32,
182    params: &ValidationParams,
183) -> ValidationResult {
184    // Quick lookup maps (built once per call).
185    let by_idx: HashMap<usize, &LabelledEntry> = entries.iter().map(|e| (e.idx, e)).collect();
186    let by_grid: HashMap<(i32, i32), usize> = entries.iter().map(|e| (e.grid, e.idx)).collect();
187
188    // Per-corner scale: in step-aware mode this is the labelled-
189    // neighbour finite-difference step; otherwise it's the global
190    // cell_size for every corner.
191    let per_corner_step = if params.use_step_aware {
192        step::local_step_per_corner(&by_idx, &by_grid)
193    } else {
194        HashMap::new()
195    };
196    let scale_at = |idx: usize| -> f32 {
197        if params.use_step_aware {
198            per_corner_step.get(&idx).copied().unwrap_or(cell_size)
199        } else {
200            cell_size
201        }
202    };
203
204    // --- 7a. Line collinearity ------------------------------------------
205    let line_flags = lines::line_collinearity_flags(&by_idx, &by_grid, params, &scale_at);
206
207    // --- 7b. Local-H residual -------------------------------------------
208    let mut residuals: HashMap<usize, f32> = HashMap::new();
209    let mut local_h_flagged: HashMap<usize, f32> = HashMap::new();
210    let mut local_h_high: HashMap<usize, f32> = HashMap::new();
211    for entry in entries {
212        let base = local_h::pick_local_h_base(&by_grid, entry.idx, entry.grid);
213        if base.len() < 4 {
214            continue;
215        }
216        let Some(resid) =
217            local_h::local_h_residual(&by_idx, entry.idx, entry.grid, &base, &by_grid)
218        else {
219            continue;
220        };
221        residuals.insert(entry.idx, resid);
222        let scale = scale_at(entry.idx);
223        let local_h_tol_px = params.local_h_tol_rel * scale;
224        if resid > local_h_tol_px {
225            local_h_flagged.insert(entry.idx, resid);
226            if resid > 2.0 * local_h_tol_px {
227                local_h_high.insert(entry.idx, resid);
228            }
229        }
230    }
231
232    // --- 7c. Step-deviation flags (optional) ----------------------------
233    let step_dev_flags = if params.use_step_aware && params.step_deviation_thresh_rel > 0.0 {
234        step::flag_step_deviations(&per_corner_step, params.step_deviation_thresh_rel)
235    } else {
236        HashSet::new()
237    };
238
239    // --- 7d. Attribution ------------------------------------------------
240    let mut blacklist: HashSet<usize> = HashSet::new();
241    // Rule 1: ≥ 2 line flags → outlier.
242    for (&idx, &count) in &line_flags {
243        if count >= 2 {
244            blacklist.insert(idx);
245        }
246    }
247    // Rule 2: high local-H residual AND ≥ 1 line flag → outlier.
248    for &idx in local_h_high.keys() {
249        if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
250            blacklist.insert(idx);
251        }
252    }
253    // Rule 3: local-H flag with no line flag BUT base neighbor flagged
254    // in a line → blacklist the worst base instead.
255    for &idx in local_h_flagged.keys() {
256        if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
257            continue;
258        }
259        if blacklist.contains(&idx) {
260            continue;
261        }
262        let Some(entry) = by_idx.get(&idx) else {
263            continue;
264        };
265        let base = local_h::pick_local_h_base(&by_grid, idx, entry.grid);
266        let mut worst: Option<(usize, u32)> = None;
267        for base_idx in &base {
268            if let Some(&flags) = line_flags.get(base_idx) {
269                if flags >= 1 && worst.map(|w| flags > w.1).unwrap_or(true) {
270                    worst = Some((*base_idx, flags));
271                }
272            }
273        }
274        if let Some((base_idx, _)) = worst {
275            blacklist.insert(base_idx);
276        }
277    }
278    // Rule 4: step-deviation flag AND ≥ 1 line flag → outlier.
279    // Rationale: a corner whose finite-difference step disagrees with
280    // the labelled-set median is a topology-consistency signal
281    // independent of line / local-H residuals. Combined with a line
282    // flag, it's strong evidence the corner is mis-labelled or sits
283    // on a different sub-grid (e.g., marker-internal corner that
284    // slipped past parity).
285    for &idx in &step_dev_flags {
286        if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
287            blacklist.insert(idx);
288        }
289    }
290
291    ValidationResult {
292        blacklist,
293        local_h_residuals: residuals,
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn entry(idx: usize, x: f32, y: f32, i: i32, j: i32) -> LabelledEntry {
302        LabelledEntry {
303            idx,
304            pixel: Point2::new(x, y),
305            grid: (i, j),
306        }
307    }
308
309    fn clean_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
310        let mut out = Vec::new();
311        let mut idx = 0;
312        for j in 0..rows {
313            for i in 0..cols {
314                out.push(entry(idx, i as f32 * s + 50.0, j as f32 * s + 50.0, i, j));
315                idx += 1;
316            }
317        }
318        out
319    }
320
321    #[test]
322    fn clean_grid_empty_blacklist() {
323        let entries = clean_grid(7, 7, 20.0);
324        let res = validate(&entries, 20.0, &ValidationParams::default());
325        assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
326    }
327
328    #[test]
329    fn displaced_interior_is_blacklisted() {
330        let mut entries = clean_grid(7, 7, 20.0);
331        // Displace (3, 3) by ~6px in both directions — failing both
332        // line fits and the local-H residual check.
333        let target = entries
334            .iter_mut()
335            .find(|e| e.grid == (3, 3))
336            .expect("(3,3) present");
337        target.pixel.x += 6.0;
338        target.pixel.y += 6.0;
339        let target_idx = target.idx;
340        let res = validate(&entries, 20.0, &ValidationParams::default());
341        assert!(
342            res.blacklist.contains(&target_idx),
343            "expected {target_idx} blacklisted, got {:?}",
344            res.blacklist
345        );
346    }
347
348    #[test]
349    fn too_few_members_per_line_is_ignored() {
350        let entries = vec![entry(0, 0.0, 0.0, 0, 0), entry(1, 20.0, 0.0, 1, 0)];
351        let res = validate(&entries, 20.0, &ValidationParams::default());
352        assert!(res.blacklist.is_empty());
353    }
354
355    #[test]
356    fn step_aware_matches_global_on_uniform_grid() {
357        // On a uniform grid, per-corner step ≈ cell_size everywhere,
358        // so step-aware mode must agree with the default mode.
359        let entries = clean_grid(7, 7, 20.0);
360        let res_default = validate(&entries, 20.0, &ValidationParams::default());
361        let res_step_aware = validate(
362            &entries,
363            20.0,
364            &ValidationParams::default().with_step_aware(0.0),
365        );
366        assert_eq!(res_default.blacklist, res_step_aware.blacklist);
367    }
368
369    #[test]
370    fn step_aware_flags_perspective_foreshortened_outlier() {
371        // Build a grid whose right column has cell pitch ~10 px (half
372        // of the rest at 20 px). On a uniform-`cell_size = 20` global
373        // tolerance, a corner displaced by 4 px in the dense column
374        // sits at 4 / 20 = 0.20 of the global cell. With step-aware
375        // (local step ~10 px), the same residual sits at 4 / 10 = 0.40
376        // — the tighter per-corner threshold catches it where the
377        // global one would defer.
378        //
379        // Layout: 5x4 grid. Columns 0..3 at 20 px pitch; column 4 at
380        // 10 px from column 3.
381        let s = 20.0_f32;
382        let mut entries = Vec::new();
383        let mut idx = 0;
384        for j in 0..4_i32 {
385            for i in 0..4_i32 {
386                entries.push(entry(idx, i as f32 * s + 50.0, j as f32 * s + 50.0, i, j));
387                idx += 1;
388            }
389        }
390        // Column 4: half-pitch (foreshortened).
391        for j in 0..4_i32 {
392            entries.push(entry(
393                idx,
394                3.0 * s + 50.0 + 0.5 * s, // x = 110 (one half-step past column 3 at x = 110)
395                j as f32 * s + 50.0,
396                4,
397                j,
398            ));
399            idx += 1;
400        }
401        // Verify baseline: no outliers.
402        let baseline = validate(&entries, s, &ValidationParams::default());
403        assert!(baseline.blacklist.is_empty(), "{:?}", baseline.blacklist);
404
405        // Displace (4, 1) — the dense-column corner — by 3 px in y.
406        let target_idx = entries
407            .iter()
408            .find(|e| e.grid == (4, 1))
409            .map(|e| e.idx)
410            .expect("(4, 1) present");
411        for e in entries.iter_mut() {
412            if e.idx == target_idx {
413                e.pixel.y += 3.0;
414            }
415        }
416
417        let global_res = validate(&entries, s, &ValidationParams::default());
418        let step_aware_res = validate(
419            &entries,
420            s,
421            &ValidationParams::default().with_step_aware(0.0),
422        );
423
424        assert!(
425            step_aware_res.blacklist.contains(&target_idx)
426                || !global_res.blacklist.contains(&target_idx),
427            "step-aware should be at least as sensitive: global={:?} step-aware={:?}",
428            global_res.blacklist,
429            step_aware_res.blacklist
430        );
431    }
432
433    #[test]
434    fn step_deviation_flag_fires_on_off_scale_corner() {
435        let s = 20.0_f32;
436        let mut entries = clean_grid(5, 5, s);
437        let new_idx = entries.len();
438        entries.push(entry(
439            new_idx,
440            4.0 * s + 0.5 * s + 50.0,
441            2.0 * s + 50.0,
442            5,
443            2,
444        ));
445        entries[new_idx].pixel.y += 4.0;
446
447        let res = validate(
448            &entries,
449            s,
450            &ValidationParams::default().with_step_aware(0.5),
451        );
452        assert!(
453            res.blacklist.contains(&new_idx),
454            "expected new corner {new_idx} blacklisted: {:?}",
455            res.blacklist
456        );
457    }
458
459    #[test]
460    fn local_step_per_corner_central_diff() {
461        // Verify the helper produces central-difference values when
462        // both neighbours are present, and one-sided otherwise.
463        let entries = [
464            entry(0, 0.0, 0.0, 0, 0),
465            entry(1, 10.0, 0.0, 1, 0),
466            entry(2, 30.0, 0.0, 2, 0), // i-step at (1, 0): central = (30 - 0)/2 = 15
467            entry(3, 30.0, 20.0, 2, 1), // j-step at (2, 0): one-sided forward = 20
468        ];
469        let by_idx: HashMap<usize, &LabelledEntry> = entries.iter().map(|e| (e.idx, e)).collect();
470        let by_grid: HashMap<(i32, i32), usize> = entries.iter().map(|e| (e.grid, e.idx)).collect();
471        let steps = step::local_step_per_corner(&by_idx, &by_grid);
472
473        // (1, 0): central i-step = 15; no j neighbours → step = 15.
474        assert!((steps[&1] - 15.0).abs() < 1e-4, "got {}", steps[&1]);
475        // (2, 0): one-sided i-step backward = 20; j-step forward = 20 → mean = 20.
476        assert!((steps[&2] - 20.0).abs() < 1e-4, "got {}", steps[&2]);
477    }
478}