Skip to main content

projective_grid/square/
validate.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 × cell_size`.
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 × cell_size` are flagged.
15//!
16//! Flags are combined via the attribution rules below into a
17//! blacklist of **indices into the input slice**:
18//!
19//! * A corner flagged in `≥ 2` lines is the outlier.
20//! * A corner with a *large* local-H residual (`> 2 × local_h_tol`) AND
21//!   at least one line flag is the outlier.
22//! * A corner with a local-H flag but NO line flag, where at least one
23//!   of its 4 base neighbors has `≥ 1` line flags, blames the worst-
24//!   line-flagged base instead (the base is the outlier).
25//! * Otherwise (isolated local-H flag with no supporting evidence),
26//!   defer — no blacklist entry in this iteration.
27//!
28//! The caller is expected to re-run the seed/grow/validate loop after
29//! updating its blacklist.
30//!
31//! # Pattern-agnostic
32//!
33//! This module has no dependency on chessboard-specific vocabulary
34//! (parity, axis clusters, label enums). Any caller that can produce
35//! a `(corner_index, pixel_position, grid_coord)` slice can use it.
36//! Consumers that carry per-stage metadata should pre-filter to the
37//! "labelled" subset before calling.
38
39use crate::homography::homography_from_4pt;
40use nalgebra::Point2;
41use std::collections::{HashMap, HashSet};
42
43/// Tolerances for the validation pass.
44///
45/// All spatial tolerances are expressed as ratios of the grid's cell
46/// size; `validate` multiplies them by the caller-supplied
47/// `cell_size` at runtime.
48#[non_exhaustive]
49#[derive(Clone, Copy, Debug)]
50pub struct ValidationParams {
51    /// Straight-line fit collinearity tolerance (fraction of
52    /// `cell_size`).
53    pub line_tol_rel: f32,
54    /// Projective-line fit collinearity tolerance (fraction of
55    /// `cell_size`). Looser than `line_tol_rel` to accommodate lens
56    /// distortion. Currently reserved for a projective-line check;
57    /// not yet consumed, but kept in the param surface so chessboard's
58    /// `DetectorParams` maps 1:1.
59    pub projective_line_tol_rel: f32,
60    /// Minimum members required to fit a line / column.
61    pub line_min_members: usize,
62    /// Local-H prediction tolerance (fraction of `cell_size`).
63    pub local_h_tol_rel: f32,
64}
65
66impl Default for ValidationParams {
67    fn default() -> Self {
68        // Matches `calib_targets_chessboard::DetectorParams`'s
69        // validation defaults so the thin chessboard wrapper stays a
70        // pure forward of the same numbers.
71        Self {
72            line_tol_rel: 0.15,
73            projective_line_tol_rel: 0.25,
74            line_min_members: 3,
75            local_h_tol_rel: 0.20,
76        }
77    }
78}
79
80impl ValidationParams {
81    /// Construct fully-specified tolerances. Use this from outside the
82    /// crate since the struct is [`#[non_exhaustive]`] — new optional
83    /// tolerances added later get sensible defaults via the returned
84    /// instance.
85    pub fn new(
86        line_tol_rel: f32,
87        projective_line_tol_rel: f32,
88        line_min_members: usize,
89        local_h_tol_rel: f32,
90    ) -> Self {
91        Self {
92            line_tol_rel,
93            projective_line_tol_rel,
94            line_min_members,
95            local_h_tol_rel,
96        }
97    }
98}
99
100/// A single labelled corner fed into [`validate`]: its caller-chosen
101/// index (carried back in `ValidationResult::blacklist`), its pixel
102/// position, and its integer grid coordinate.
103///
104/// The index is opaque to this module — callers may pick any scheme
105/// (direct slice indices, corner struct fields, etc.) as long as the
106/// same scheme maps `blacklist` entries back to their originals.
107#[derive(Clone, Copy, Debug)]
108pub struct LabelledEntry {
109    pub idx: usize,
110    pub pixel: Point2<f32>,
111    pub grid: (i32, i32),
112}
113
114/// Outcome of one validation pass.
115#[derive(Debug, Default)]
116pub struct ValidationResult {
117    /// Corner indices to blacklist (attribution has been applied).
118    pub blacklist: HashSet<usize>,
119    /// For each labelled corner, its local-H residual in pixels
120    /// (`None` when fewer than 4 non-collinear neighbors were
121    /// available).
122    pub local_h_residuals: HashMap<usize, f32>,
123}
124
125/// Run both validation passes and produce a blacklist.
126pub fn validate(
127    entries: &[LabelledEntry],
128    cell_size: f32,
129    params: &ValidationParams,
130) -> ValidationResult {
131    let line_tol_px = params.line_tol_rel * cell_size;
132    let local_h_tol_px = params.local_h_tol_rel * cell_size;
133    let high_tol_px = 2.0 * local_h_tol_px;
134
135    // Quick lookup maps (built once per call).
136    let by_idx: HashMap<usize, &LabelledEntry> = entries.iter().map(|e| (e.idx, e)).collect();
137    let by_grid: HashMap<(i32, i32), usize> = entries.iter().map(|e| (e.grid, e.idx)).collect();
138
139    // --- 7a. Line collinearity ------------------------------------------
140    let line_flags = line_collinearity_flags(&by_idx, &by_grid, line_tol_px, params);
141
142    // --- 7b. Local-H residual -------------------------------------------
143    let mut residuals: HashMap<usize, f32> = HashMap::new();
144    let mut local_h_flagged: HashMap<usize, f32> = HashMap::new();
145    for entry in entries {
146        let base = pick_local_h_base(&by_grid, entry.idx, entry.grid);
147        if base.len() < 4 {
148            continue;
149        }
150        let Some(resid) = local_h_residual(&by_idx, entry.idx, entry.grid, &base, &by_grid) else {
151            continue;
152        };
153        residuals.insert(entry.idx, resid);
154        if resid > local_h_tol_px {
155            local_h_flagged.insert(entry.idx, resid);
156        }
157    }
158
159    // --- 7c. Attribution -------------------------------------------------
160    let mut blacklist: HashSet<usize> = HashSet::new();
161    // Rule 1: ≥ 2 line flags → outlier.
162    for (&idx, &count) in &line_flags {
163        if count >= 2 {
164            blacklist.insert(idx);
165        }
166    }
167    // Rule 2: high local-H residual AND ≥ 1 line flag → outlier.
168    for (&idx, &resid) in &local_h_flagged {
169        if resid > high_tol_px && line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
170            blacklist.insert(idx);
171        }
172    }
173    // Rule 3: local-H flag with no line flag BUT base neighbor flagged
174    // in a line → blacklist the worst base instead.
175    for &idx in local_h_flagged.keys() {
176        if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
177            continue;
178        }
179        if blacklist.contains(&idx) {
180            continue;
181        }
182        let Some(entry) = by_idx.get(&idx) else {
183            continue;
184        };
185        let base = pick_local_h_base(&by_grid, idx, entry.grid);
186        let mut worst: Option<(usize, u32)> = None;
187        for base_idx in &base {
188            if let Some(&flags) = line_flags.get(base_idx) {
189                if flags >= 1 && worst.map(|w| flags > w.1).unwrap_or(true) {
190                    worst = Some((*base_idx, flags));
191                }
192            }
193        }
194        if let Some((base_idx, _)) = worst {
195            blacklist.insert(base_idx);
196        }
197    }
198
199    ValidationResult {
200        blacklist,
201        local_h_residuals: residuals,
202    }
203}
204
205// --- line collinearity ----------------------------------------------------
206
207fn line_collinearity_flags(
208    by_idx: &HashMap<usize, &LabelledEntry>,
209    by_grid: &HashMap<(i32, i32), usize>,
210    tol_px: f32,
211    params: &ValidationParams,
212) -> HashMap<usize, u32> {
213    let mut flags: HashMap<usize, u32> = HashMap::new();
214
215    // Group by row (j = const) and column (i = const).
216    let mut rows: HashMap<i32, Vec<(i32, usize)>> = HashMap::new();
217    let mut cols: HashMap<i32, Vec<(i32, usize)>> = HashMap::new();
218    for (&(i, j), &idx) in by_grid {
219        rows.entry(j).or_default().push((i, idx));
220        cols.entry(i).or_default().push((j, idx));
221    }
222
223    let line_min = params.line_min_members;
224
225    for (_, mut members) in rows {
226        if members.len() < line_min {
227            continue;
228        }
229        members.sort_by_key(|(i, _)| *i);
230        flag_line(by_idx, &members, tol_px, &mut flags);
231    }
232    for (_, mut members) in cols {
233        if members.len() < line_min {
234            continue;
235        }
236        members.sort_by_key(|(j, _)| *j);
237        flag_line(by_idx, &members, tol_px, &mut flags);
238    }
239    flags
240}
241
242/// Fit a total-least-squares line to the member pixel positions; flag
243/// any member whose perpendicular distance exceeds `tol_px`.
244fn flag_line(
245    by_idx: &HashMap<usize, &LabelledEntry>,
246    members: &[(i32, usize)],
247    tol_px: f32,
248    flags: &mut HashMap<usize, u32>,
249) {
250    let n = members.len() as f32;
251    let mut cx = 0.0_f32;
252    let mut cy = 0.0_f32;
253    for (_, idx) in members {
254        let Some(e) = by_idx.get(idx) else { continue };
255        cx += e.pixel.x;
256        cy += e.pixel.y;
257    }
258    cx /= n;
259    cy /= n;
260    let mut sxx = 0.0_f32;
261    let mut sxy = 0.0_f32;
262    let mut syy = 0.0_f32;
263    for (_, idx) in members {
264        let Some(e) = by_idx.get(idx) else { continue };
265        let dx = e.pixel.x - cx;
266        let dy = e.pixel.y - cy;
267        sxx += dx * dx;
268        sxy += dx * dy;
269        syy += dy * dy;
270    }
271    let trace = sxx + syy;
272    let det = sxx * syy - sxy * sxy;
273    let disc = (trace * trace * 0.25 - det).max(0.0).sqrt();
274    let lambda = trace * 0.5 + disc;
275    let (vx, vy) = if sxy.abs() > f32::EPSILON {
276        (sxy, lambda - sxx)
277    } else if sxx >= syy {
278        (1.0, 0.0)
279    } else {
280        (0.0, 1.0)
281    };
282    let vn = (vx * vx + vy * vy).sqrt().max(f32::EPSILON);
283    let ux = vx / vn;
284    let uy = vy / vn;
285    for (_, idx) in members {
286        let Some(e) = by_idx.get(idx) else { continue };
287        let dx = e.pixel.x - cx;
288        let dy = e.pixel.y - cy;
289        let resid = (dx * -uy + dy * ux).abs();
290        if resid > tol_px {
291            *flags.entry(*idx).or_insert(0) += 1;
292        }
293    }
294}
295
296// --- local-H residual -----------------------------------------------------
297
298/// Pick the 4 grid-closest labelled neighbors of `c_idx` at `pos`
299/// that form a non-degenerate quad (i.e., not all collinear in grid
300/// coordinates).
301fn pick_local_h_base(
302    by_grid: &HashMap<(i32, i32), usize>,
303    c_idx: usize,
304    pos: (i32, i32),
305) -> Vec<usize> {
306    let mut cands: Vec<((i32, i32), usize, f32)> = Vec::new();
307    for dj in -2..=2_i32 {
308        for di in -2..=2_i32 {
309            if di == 0 && dj == 0 {
310                continue;
311            }
312            let neigh = (pos.0 + di, pos.1 + dj);
313            if let Some(&idx) = by_grid.get(&neigh) {
314                if idx == c_idx {
315                    continue;
316                }
317                let d = ((di * di + dj * dj) as f32).sqrt();
318                cands.push((neigh, idx, d));
319            }
320        }
321    }
322    cands.sort_by(|a, b| a.2.total_cmp(&b.2));
323
324    let mut chosen: Vec<((i32, i32), usize)> = Vec::new();
325    for (ij, idx, _) in &cands {
326        chosen.push((*ij, *idx));
327        if chosen.len() == 4 && !are_collinear_grid(&chosen) {
328            return chosen.iter().map(|(_, i)| *i).collect();
329        }
330        if chosen.len() >= 4 {
331            chosen.pop();
332        }
333    }
334    chosen.iter().map(|(_, i)| *i).collect()
335}
336
337fn are_collinear_grid(pts: &[((i32, i32), usize)]) -> bool {
338    if pts.len() < 3 {
339        return false;
340    }
341    let (i0, j0) = pts[0].0;
342    let (i1, j1) = pts[1].0;
343    let dx1 = i1 - i0;
344    let dy1 = j1 - j0;
345    for &((i, j), _) in &pts[2..] {
346        let dx = i - i0;
347        let dy = j - j0;
348        if dx1 * dy - dy1 * dx != 0 {
349            return false;
350        }
351    }
352    true
353}
354
355fn local_h_residual(
356    by_idx: &HashMap<usize, &LabelledEntry>,
357    c_idx: usize,
358    c_grid: (i32, i32),
359    base: &[usize],
360    by_grid: &HashMap<(i32, i32), usize>,
361) -> Option<f32> {
362    if base.len() < 4 {
363        return None;
364    }
365    // Resolve each base index back to its grid coordinate. The base
366    // list came from neighbourhood enumeration so each one is present
367    // in `by_grid` under some unique key.
368    let mut base_grid: [(i32, i32); 4] = [(0, 0); 4];
369    let mut base_pixel: [Point2<f32>; 4] = [Point2::new(0.0, 0.0); 4];
370    for (k, &b_idx) in base.iter().take(4).enumerate() {
371        let grid = by_grid
372            .iter()
373            .find_map(|(&g, &v)| if v == b_idx { Some(g) } else { None })?;
374        base_grid[k] = grid;
375        base_pixel[k] = by_idx.get(&b_idx)?.pixel;
376    }
377
378    let grid_pts = [
379        Point2::new(base_grid[0].0 as f32, base_grid[0].1 as f32),
380        Point2::new(base_grid[1].0 as f32, base_grid[1].1 as f32),
381        Point2::new(base_grid[2].0 as f32, base_grid[2].1 as f32),
382        Point2::new(base_grid[3].0 as f32, base_grid[3].1 as f32),
383    ];
384    let h = homography_from_4pt(&grid_pts, &base_pixel)?;
385
386    let c_pixel = by_idx.get(&c_idx)?.pixel;
387    let pred = h.apply(Point2::new(c_grid.0 as f32, c_grid.1 as f32));
388    let dx = pred.x - c_pixel.x;
389    let dy = pred.y - c_pixel.y;
390    Some((dx * dx + dy * dy).sqrt())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn entry(idx: usize, x: f32, y: f32, i: i32, j: i32) -> LabelledEntry {
398        LabelledEntry {
399            idx,
400            pixel: Point2::new(x, y),
401            grid: (i, j),
402        }
403    }
404
405    fn clean_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
406        let mut out = Vec::new();
407        let mut idx = 0;
408        for j in 0..rows {
409            for i in 0..cols {
410                out.push(entry(idx, i as f32 * s + 50.0, j as f32 * s + 50.0, i, j));
411                idx += 1;
412            }
413        }
414        out
415    }
416
417    #[test]
418    fn clean_grid_empty_blacklist() {
419        let entries = clean_grid(7, 7, 20.0);
420        let res = validate(&entries, 20.0, &ValidationParams::default());
421        assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
422    }
423
424    #[test]
425    fn displaced_interior_is_blacklisted() {
426        let mut entries = clean_grid(7, 7, 20.0);
427        // Displace (3, 3) by ~6px in both directions — failing both
428        // line fits and the local-H residual check.
429        let target = entries
430            .iter_mut()
431            .find(|e| e.grid == (3, 3))
432            .expect("(3,3) present");
433        target.pixel.x += 6.0;
434        target.pixel.y += 6.0;
435        let target_idx = target.idx;
436        let res = validate(&entries, 20.0, &ValidationParams::default());
437        assert!(
438            res.blacklist.contains(&target_idx),
439            "expected {target_idx} blacklisted, got {:?}",
440            res.blacklist
441        );
442    }
443
444    #[test]
445    fn too_few_members_per_line_is_ignored() {
446        let entries = vec![entry(0, 0.0, 0.0, 0, 0), entry(1, 20.0, 0.0, 1, 0)];
447        let res = validate(&entries, 20.0, &ValidationParams::default());
448        assert!(res.blacklist.is_empty());
449    }
450}