Skip to main content

projective_grid/shared/validate/
mod.rs

1//! Post-growth validation for a labelled square grid.
2//!
3//! Two independent checks run over the labelled set by default:
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 topological recovery/validate loop after
42//! updating its blacklist.
43//!
44//! An optional **edge-shape gate** is available for final validation
45//! of a completed labelled grid. It rejects labels with too little
46//! cardinal support, bad adjacent-edge continuation, or no valid
47//! adjacent square cell under local opposite-side consistency. This
48//! gate is opt-in so the historical grow-time validator remains
49//! conservative.
50//!
51//! # Pattern-agnostic
52//!
53//! This module has no dependency on target-specific vocabulary such as
54//! feature-class labels or target IDs. Any caller that can produce
55//! a `(corner_index, pixel_position, grid_coord)` slice can use it.
56//! Consumers that carry per-stage metadata should pre-filter to the
57//! "labelled" subset before calling.
58
59mod edge_shape;
60mod lines;
61mod local_h;
62mod step;
63pub mod wrong_label_filters;
64
65use nalgebra::Point2;
66use std::collections::{HashMap, HashSet};
67
68/// Tolerances for the validation pass.
69///
70/// All spatial tolerances are expressed as ratios of either the
71/// caller-supplied global `cell_size` or — when [`use_step_aware`] is
72/// set — the per-corner local step derived from labelled grid
73/// neighbours.
74///
75/// [`use_step_aware`]: ValidationParams::use_step_aware
76#[non_exhaustive]
77#[derive(Clone, Copy, Debug)]
78pub struct ValidationParams {
79    /// Straight-line fit collinearity tolerance (fraction of the
80    /// per-corner scale).
81    pub line_tol_rel: f32,
82    /// Minimum members required to fit a line / column.
83    pub line_min_members: usize,
84    /// Local-H prediction tolerance (fraction of the per-corner
85    /// scale).
86    pub local_h_tol_rel: f32,
87    /// When `true`, line and local-H thresholds use a per-corner
88    /// local step computed from labelled grid neighbours via central
89    /// or one-sided finite differences (`(step_u + step_v) / 2`).
90    /// Corners without enough labelled neighbours fall back to the
91    /// global `cell_size`.
92    ///
93    /// Set this when the grid is non-uniform in pixel space —
94    /// perspective foreshortening, radial distortion, or rectified-
95    /// then-rasterised images. Has no effect on uniform grids.
96    pub use_step_aware: bool,
97    /// When `> 0` and [`use_step_aware`] is set, an additional flag
98    /// fires for corners whose local step deviates from the labelled-
99    /// set median by more than `step_deviation_thresh_rel` (relative).
100    /// E.g. `0.5` flags corners whose step is < 1/(1+0.5) of the
101    /// median or > (1+0.5)× the median.
102    ///
103    /// Combined with line flags via the existing attribution rules
104    /// (rule 4: step-deviation flag + ≥ 1 line flag → outlier).
105    /// Set to `0.0` to disable.
106    ///
107    /// [`use_step_aware`]: ValidationParams::use_step_aware
108    pub step_deviation_thresh_rel: f32,
109    /// Optional final-gate checks for local square-grid edge shape.
110    ///
111    /// Disabled by default to preserve the conservative grow-time
112    /// validator. Enable this only for final precision gates that may
113    /// remove labels or refuse the whole detection.
114    pub edge_shape: Option<EdgeShapeParams>,
115}
116
117impl Default for ValidationParams {
118    fn default() -> Self {
119        // Conservative defaults inherited from the mature square-grid
120        // detector path. Step-aware mode is opt-in.
121        Self {
122            line_tol_rel: 0.15,
123            line_min_members: 3,
124            local_h_tol_rel: 0.20,
125            use_step_aware: false,
126            step_deviation_thresh_rel: 0.0,
127            edge_shape: None,
128        }
129    }
130}
131
132impl ValidationParams {
133    /// Construct fully-specified core tolerances. Step-aware mode is
134    /// off by default; call [`with_step_aware`] to enable it.
135    ///
136    /// [`with_step_aware`]: ValidationParams::with_step_aware
137    pub fn new(line_tol_rel: f32, line_min_members: usize, local_h_tol_rel: f32) -> Self {
138        Self {
139            line_tol_rel,
140            line_min_members,
141            local_h_tol_rel,
142            use_step_aware: false,
143            step_deviation_thresh_rel: 0.0,
144            edge_shape: None,
145        }
146    }
147
148    /// Enable per-corner step-aware thresholds. Pass
149    /// `deviation_thresh_rel = 0.0` for thresholds-only without the
150    /// extra step-deviation flag.
151    pub fn with_step_aware(mut self, deviation_thresh_rel: f32) -> Self {
152        self.use_step_aware = true;
153        self.step_deviation_thresh_rel = deviation_thresh_rel;
154        self
155    }
156
157    /// Builder-style override for [`Self::line_tol_rel`]. Set to
158    /// `f32::INFINITY` to disable the line-collinearity check.
159    pub fn with_line_tol_rel(mut self, value: f32) -> Self {
160        self.line_tol_rel = value;
161        self
162    }
163
164    /// Builder-style override for [`Self::local_h_tol_rel`]. Set to
165    /// `f32::INFINITY` to disable the local-H residual check.
166    pub fn with_local_h_tol_rel(mut self, value: f32) -> Self {
167        self.local_h_tol_rel = value;
168        self
169    }
170
171    /// Builder-style no-op kept for facade compatibility.
172    ///
173    /// The advanced validator has **no** unconditional edge-length-band
174    /// gate (the historical generic `validate` did; the advanced
175    /// validator replaces it with the opt-in [`Self::with_edge_shape_gate`]).
176    /// This builder accepts the value so callers that disable validation
177    /// by pushing every tolerance to `f32::INFINITY` keep compiling and
178    /// keep their intent — there is simply no band gate to widen here.
179    /// To disable the validator entirely, set `line_tol_rel` and
180    /// `local_h_tol_rel` to `f32::INFINITY` and leave the edge-shape gate
181    /// off (the default).
182    pub fn with_edge_length_band_rel(self, _value: f32) -> Self {
183        self
184    }
185
186    /// Enable local edge-shape validation for final labelled-grid
187    /// precision gates.
188    pub fn with_edge_shape_gate(mut self, edge_shape: EdgeShapeParams) -> Self {
189        self.edge_shape = Some(edge_shape);
190        self
191    }
192}
193
194/// Tolerances for local square-grid edge-shape validation.
195#[non_exhaustive]
196#[derive(Clone, Copy, Debug)]
197pub struct EdgeShapeParams {
198    /// Minimum number of cardinally adjacent labelled neighbours
199    /// required for a label to survive.
200    pub min_cardinal_degree: u8,
201    /// Maximum angle change, in degrees, allowed between two adjacent
202    /// edges that continue through a shared vertex.
203    pub continuation_angle_tol_deg: f32,
204    /// Maximum edge-length ratio allowed between two adjacent edges
205    /// that continue through a weakly supported shared vertex.
206    /// Well-supported interior vertices are checked by direction and
207    /// cell shape instead, because perspective can legitimately make
208    /// adjacent samples along one projected line differ in length.
209    pub continuation_length_ratio_max: f32,
210    /// Maximum angle difference, in degrees, allowed between opposite
211    /// sides of a complete local cell.
212    pub cell_opposite_angle_tol_deg: f32,
213    /// Maximum length ratio allowed between opposite sides of a
214    /// complete local cell.
215    pub cell_opposite_length_ratio_max: f32,
216}
217
218impl Default for EdgeShapeParams {
219    fn default() -> Self {
220        Self {
221            min_cardinal_degree: 2,
222            continuation_angle_tol_deg: 8.0,
223            continuation_length_ratio_max: 1.18,
224            cell_opposite_angle_tol_deg: 8.0,
225            cell_opposite_length_ratio_max: 1.10,
226        }
227    }
228}
229
230/// Per-label diagnostics from local edge-shape validation.
231#[derive(Clone, Copy, Debug, Default)]
232pub struct EdgeShapeDiagnostic {
233    /// Number of cardinally adjacent labelled neighbours.
234    pub cardinal_degree: u8,
235    /// Whether the coordinate lies on the labelled-set bounding box.
236    pub is_bbox_boundary: bool,
237    /// Maximum angle change across supported line continuations, in degrees.
238    pub max_continuation_angle_deg: Option<f32>,
239    /// Maximum length ratio across supported line continuations.
240    pub max_continuation_length_ratio: Option<f32>,
241    /// Number of complete adjacent square cells.
242    pub adjacent_cell_count: u8,
243    /// Number of adjacent square cells that satisfy opposite-side checks.
244    pub valid_adjacent_cell_count: u8,
245    /// Maximum opposite-side angle difference across adjacent cells, in degrees.
246    pub max_cell_opposite_angle_deg: Option<f32>,
247    /// Maximum opposite-side length ratio across adjacent cells.
248    pub max_cell_opposite_length_ratio: Option<f32>,
249}
250
251/// A single labelled corner fed into [`validate`]: its caller-chosen
252/// index (carried back in `ValidationResult::blacklist`), its pixel
253/// position, and its integer grid coordinate.
254///
255/// The index is opaque to this module — callers may pick any scheme
256/// (direct slice indices, corner struct fields, etc.) as long as the
257/// same scheme maps `blacklist` entries back to their originals.
258#[derive(Clone, Copy, Debug)]
259pub struct LabelledEntry {
260    /// Caller-chosen opaque index, carried back in `ValidationResult::blacklist`.
261    pub idx: usize,
262    /// The corner's position in image pixels.
263    pub pixel: Point2<f32>,
264    /// The corner's integer `(i, j)` grid coordinate.
265    pub grid: (i32, i32),
266}
267
268/// Outcome of one validation pass.
269#[derive(Debug, Default)]
270pub struct ValidationResult {
271    /// Corner indices to blacklist (attribution has been applied).
272    pub blacklist: HashSet<usize>,
273    /// For each labelled corner, its local-H residual in pixels
274    /// (`None` when fewer than 4 non-collinear neighbors were
275    /// available).
276    pub local_h_residuals: HashMap<usize, f32>,
277    /// Edge-shape diagnostics keyed by caller-chosen label index.
278    /// Empty when the edge-shape gate is disabled.
279    pub edge_shape_diagnostics: HashMap<usize, EdgeShapeDiagnostic>,
280    /// Edge-shape rejection reason keyed by caller-chosen label index.
281    /// Empty when the edge-shape gate is disabled.
282    pub edge_shape_reasons: HashMap<usize, &'static str>,
283}
284
285/// Run both validation passes and produce a blacklist.
286#[cfg_attr(
287    feature = "tracing",
288    tracing::instrument(
289        level = "info",
290        skip_all,
291        fields(num_labelled = entries.len(), cell_size = cell_size),
292    )
293)]
294pub fn validate(
295    entries: &[LabelledEntry],
296    cell_size: f32,
297    params: &ValidationParams,
298) -> ValidationResult {
299    // Quick lookup maps (built once per call).
300    //
301    // `by_grid` is injective on its key (each `grid` cell is unique), so its
302    // contents are order-independent. `by_idx` is NOT: the topological walk can
303    // label the same `source_index` at two grid cells, so an `idx` may appear in
304    // `entries` twice with different `grid`/pixel — and a plain `collect` is
305    // last-write-wins in the caller's `HashMap` iteration order. Build it from a
306    // `(grid, idx)`-sorted pass so the surviving entry for a duplicated `idx`
307    // (used by step-aware scale and Rule 3's base pick) is reproducible
308    // run-to-run; this is part of the duplicate-label determinism contract on
309    // the residual loop below.
310    let mut sorted_entries: Vec<&LabelledEntry> = entries.iter().collect();
311    sorted_entries.sort_unstable_by_key(|e| (e.grid, e.idx));
312    let by_idx: HashMap<usize, &LabelledEntry> =
313        sorted_entries.iter().map(|&e| (e.idx, e)).collect();
314    let by_grid: HashMap<(i32, i32), usize> = entries.iter().map(|e| (e.grid, e.idx)).collect();
315
316    // Per-corner scale: in step-aware mode this is the labelled-
317    // neighbour finite-difference step; otherwise it's the global
318    // cell_size for every corner.
319    let per_corner_step = if params.use_step_aware {
320        step::local_step_per_corner(&by_idx, &by_grid)
321    } else {
322        HashMap::new()
323    };
324    let scale_at = |idx: usize| -> f32 {
325        if params.use_step_aware {
326            per_corner_step.get(&idx).copied().unwrap_or(cell_size)
327        } else {
328            cell_size
329        }
330    };
331
332    // --- 7a. Line collinearity ------------------------------------------
333    let line_flags = lines::line_collinearity_flags(&by_idx, &by_grid, params, &scale_at);
334
335    // --- 7b. Local-H residual -------------------------------------------
336    // Visit entries in a fixed `(grid, idx)` order, not slice order.
337    //
338    // Determinism contract: the per-corner maps below are keyed by `idx`, but
339    // the topological walk can label the same `source_index` at two different
340    // grid cells (a non-injective labelled set). Such an `idx` then appears in
341    // `entries` twice — once per grid cell — each with a *different* residual
342    // (its local homography is fit at a different grid position). Inserting by
343    // `idx` is last-write-wins, so in slice order (which is the caller's
344    // `HashMap` iteration order) the stored residual would depend on which
345    // duplicate was visited last, varying per process. That flipped a
346    // borderline corner's drop and was the residual source of the
347    // topological→ChArUco recall flake. Sorting the visitation pins which
348    // duplicate wins without changing the result for the injective (common)
349    // case.
350    let mut entry_order: Vec<usize> = (0..entries.len()).collect();
351    entry_order.sort_unstable_by_key(|&k| (entries[k].grid, entries[k].idx));
352    let mut residuals: HashMap<usize, f32> = HashMap::new();
353    let mut local_h_flagged: HashMap<usize, f32> = HashMap::new();
354    let mut local_h_high: HashMap<usize, f32> = HashMap::new();
355    for &k in &entry_order {
356        let entry = &entries[k];
357        let base = local_h::pick_local_h_base(&by_grid, entry.idx, entry.grid);
358        if base.len() < 4 {
359            continue;
360        }
361        let Some(resid) = local_h::local_h_residual(&by_idx, entry.idx, entry.grid, &base) else {
362            continue;
363        };
364        residuals.insert(entry.idx, resid);
365        let scale = scale_at(entry.idx);
366        let local_h_tol_px = params.local_h_tol_rel * scale;
367        if resid > local_h_tol_px {
368            local_h_flagged.insert(entry.idx, resid);
369            if resid > 2.0 * local_h_tol_px {
370                local_h_high.insert(entry.idx, resid);
371            }
372        }
373    }
374
375    // --- 7c. Step-deviation flags (optional) ----------------------------
376    let step_dev_flags = if params.use_step_aware && params.step_deviation_thresh_rel > 0.0 {
377        step::flag_step_deviations(&per_corner_step, params.step_deviation_thresh_rel)
378    } else {
379        HashSet::new()
380    };
381
382    // --- 7d. Attribution ------------------------------------------------
383    let mut blacklist: HashSet<usize> = HashSet::new();
384    // Rule 1: ≥ 2 line flags → outlier.
385    for (&idx, &count) in &line_flags {
386        if count >= 2 {
387            blacklist.insert(idx);
388        }
389    }
390    // Rule 2: high local-H residual AND ≥ 1 line flag → outlier.
391    for &idx in local_h_high.keys() {
392        if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
393            blacklist.insert(idx);
394        }
395    }
396    // Rule 3: local-H flag with no line flag BUT base neighbor flagged
397    // in a line → blacklist the worst base instead.
398    //
399    // Determinism contract: unlike Rules 1/2/4 (each an unconditional
400    // per-`idx` set insertion, so iteration order is irrelevant), this rule
401    // is order-sensitive. It both reads (`blacklist.contains(&idx)`) and
402    // writes (`blacklist.insert(base_idx)`) the shared blacklist, and a
403    // `base_idx` it inserts for one corner may be the `idx` of a later
404    // corner — which then gets skipped. So the *set* of blacklisted corners
405    // depends on the visitation order. `local_h_flagged` is a `HashMap`, so
406    // iterating its keys directly would make the drop set depend on
407    // per-process `HashMap` seeding — the residual source of the
408    // topological→ChArUco recall flake. Visit the flagged corners in a fixed
409    // `idx` order so the resolution is reproducible run-to-run.
410    let mut local_h_flagged_order: Vec<usize> = local_h_flagged.keys().copied().collect();
411    local_h_flagged_order.sort_unstable();
412    for idx in local_h_flagged_order {
413        if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
414            continue;
415        }
416        if blacklist.contains(&idx) {
417            continue;
418        }
419        let Some(entry) = by_idx.get(&idx) else {
420            continue;
421        };
422        let base = local_h::pick_local_h_base(&by_grid, idx, entry.grid);
423        let mut worst: Option<(usize, u32)> = None;
424        for &(base_idx, _) in &base {
425            if let Some(&flags) = line_flags.get(&base_idx) {
426                if flags >= 1 && worst.map(|w| flags > w.1).unwrap_or(true) {
427                    worst = Some((base_idx, flags));
428                }
429            }
430        }
431        if let Some((base_idx, _)) = worst {
432            blacklist.insert(base_idx);
433        }
434    }
435    // Rule 4: step-deviation flag AND ≥ 1 line flag → outlier.
436    // Rationale: a corner whose finite-difference step disagrees with
437    // the labelled-set median is a topology-consistency signal
438    // independent of line / local-H residuals. Combined with a line
439    // flag, it's strong evidence the corner is mis-labelled or sits
440    // on a different sub-grid.
441    for &idx in &step_dev_flags {
442        if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
443            blacklist.insert(idx);
444        }
445    }
446
447    // --- 7e. Final edge-shape gate (optional) --------------------------
448    let (edge_shape_diagnostics, edge_shape_reasons) =
449        if let Some(edge_shape_params) = params.edge_shape {
450            let (diagnostics, reasons) =
451                edge_shape::evaluate_edge_shape(&by_idx, &by_grid, edge_shape_params);
452            blacklist.extend(reasons.keys().copied());
453            (diagnostics, reasons)
454        } else {
455            (HashMap::new(), HashMap::new())
456        };
457
458    ValidationResult {
459        blacklist,
460        local_h_residuals: residuals,
461        edge_shape_diagnostics,
462        edge_shape_reasons,
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn entry(idx: usize, x: f32, y: f32, i: i32, j: i32) -> LabelledEntry {
471        LabelledEntry {
472            idx,
473            pixel: Point2::new(x, y),
474            grid: (i, j),
475        }
476    }
477
478    fn clean_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
479        let mut out = Vec::new();
480        let mut idx = 0;
481        for j in 0..rows {
482            for i in 0..cols {
483                out.push(entry(idx, i as f32 * s + 50.0, j as f32 * s + 50.0, i, j));
484                idx += 1;
485            }
486        }
487        out
488    }
489
490    fn edge_gate_params() -> ValidationParams {
491        ValidationParams::new(999.0, 99, 999.0).with_edge_shape_gate(EdgeShapeParams::default())
492    }
493
494    fn mild_perspective_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
495        let mut out = Vec::new();
496        let mut idx = 0;
497        for j in 0..rows {
498            for i in 0..cols {
499                let u = i as f32;
500                let v = j as f32;
501                let denom = 1.0 + 0.01 * u + 0.006 * v;
502                let x = 50.0 + (s * (u + 0.08 * v)) / denom;
503                let y = 50.0 + (s * (v + 0.04 * u)) / denom;
504                out.push(entry(idx, x, y, i, j));
505                idx += 1;
506            }
507        }
508        out
509    }
510
511    fn mild_radial_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
512        let mut out = Vec::new();
513        let mut idx = 0;
514        let cx = (cols - 1) as f32 * 0.5;
515        let cy = (rows - 1) as f32 * 0.5;
516        for j in 0..rows {
517            for i in 0..cols {
518                let u = i as f32 - cx;
519                let v = j as f32 - cy;
520                let r2 = u * u + v * v;
521                let k = 1.0 + 0.006 * r2;
522                let x = 150.0 + s * u * k;
523                let y = 150.0 + s * v * k;
524                out.push(entry(idx, x, y, i, j));
525                idx += 1;
526            }
527        }
528        out
529    }
530
531    #[test]
532    fn clean_grid_empty_blacklist() {
533        let entries = clean_grid(7, 7, 20.0);
534        let res = validate(&entries, 20.0, &ValidationParams::default());
535        assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
536    }
537
538    #[test]
539    fn displaced_interior_is_blacklisted() {
540        let mut entries = clean_grid(7, 7, 20.0);
541        // Displace (3, 3) by ~6px in both directions — failing both
542        // line fits and the local-H residual check.
543        let target = entries
544            .iter_mut()
545            .find(|e| e.grid == (3, 3))
546            .expect("(3,3) present");
547        target.pixel.x += 6.0;
548        target.pixel.y += 6.0;
549        let target_idx = target.idx;
550        let res = validate(&entries, 20.0, &ValidationParams::default());
551        assert!(
552            res.blacklist.contains(&target_idx),
553            "expected {target_idx} blacklisted, got {:?}",
554            res.blacklist
555        );
556    }
557
558    #[test]
559    fn too_few_members_per_line_is_ignored() {
560        let entries = vec![entry(0, 0.0, 0.0, 0, 0), entry(1, 20.0, 0.0, 1, 0)];
561        let res = validate(&entries, 20.0, &ValidationParams::default());
562        assert!(res.blacklist.is_empty());
563    }
564
565    #[test]
566    fn edge_shape_clean_grid_passes() {
567        let entries = clean_grid(7, 7, 20.0);
568        let res = validate(&entries, 20.0, &edge_gate_params());
569        assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
570    }
571
572    #[test]
573    fn edge_shape_mild_perspective_grid_passes() {
574        let entries = mild_perspective_grid(7, 7, 20.0);
575        let res = validate(&entries, 20.0, &edge_gate_params());
576        assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
577    }
578
579    #[test]
580    fn edge_shape_mild_radial_grid_passes() {
581        let entries = mild_radial_grid(7, 7, 20.0);
582        let res = validate(&entries, 20.0, &edge_gate_params());
583        assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
584    }
585
586    #[test]
587    fn edge_shape_rejects_isolated_point() {
588        let mut entries = clean_grid(2, 2, 20.0);
589        let isolated_idx = entries.len();
590        entries.push(entry(isolated_idx, 150.0, 150.0, 5, 5));
591        let res = validate(&entries, 20.0, &edge_gate_params());
592        assert!(
593            res.blacklist.contains(&isolated_idx),
594            "blacklist={:?}",
595            res.blacklist
596        );
597        assert_eq!(
598            res.edge_shape_reasons.get(&isolated_idx).copied(),
599            Some("low-cardinal-degree")
600        );
601    }
602
603    #[test]
604    fn edge_shape_rejects_degree_one_dangling_point() {
605        let mut entries = clean_grid(2, 2, 20.0);
606        let dangling_idx = entries.len();
607        entries.push(entry(dangling_idx, 90.0, 50.0, 2, 0));
608        let res = validate(&entries, 20.0, &edge_gate_params());
609        assert!(
610            res.blacklist.contains(&dangling_idx),
611            "blacklist={:?}",
612            res.blacklist
613        );
614        assert_eq!(res.edge_shape_diagnostics[&dangling_idx].cardinal_degree, 1);
615    }
616
617    #[test]
618    fn edge_shape_rejects_bad_continuation_across_vertex() {
619        let mut entries = clean_grid(3, 3, 20.0);
620        let target = entries
621            .iter_mut()
622            .find(|e| e.grid == (1, 1))
623            .expect("(1,1) present");
624        target.pixel.x += 8.0;
625        let target_idx = target.idx;
626        let res = validate(&entries, 20.0, &edge_gate_params());
627        assert!(
628            res.blacklist.contains(&target_idx),
629            "blacklist={:?} diagnostics={:?}",
630            res.blacklist,
631            res.edge_shape_diagnostics.get(&target_idx)
632        );
633        assert_eq!(
634            res.edge_shape_reasons.get(&target_idx).copied(),
635            Some("bad-continuation")
636        );
637    }
638
639    #[test]
640    fn edge_shape_rejects_corner_with_no_valid_adjacent_cell() {
641        let mut entries = clean_grid(2, 2, 20.0);
642        let target = entries
643            .iter_mut()
644            .find(|e| e.grid == (1, 1))
645            .expect("(1,1) present");
646        target.pixel.x += 8.0;
647        let target_idx = target.idx;
648        let res = validate(&entries, 20.0, &edge_gate_params());
649        assert!(
650            res.blacklist.contains(&target_idx),
651            "blacklist={:?} diagnostics={:?}",
652            res.blacklist,
653            res.edge_shape_diagnostics.get(&target_idx)
654        );
655        assert_eq!(
656            res.edge_shape_diagnostics[&target_idx].adjacent_cell_count,
657            1
658        );
659        assert_eq!(
660            res.edge_shape_reasons.get(&target_idx).copied(),
661            Some("no-valid-adjacent-cell")
662        );
663    }
664
665    #[test]
666    fn edge_shape_complete_two_by_two_cell_keeps_degree_two_corners() {
667        let entries = clean_grid(2, 2, 20.0);
668        let res = validate(&entries, 20.0, &edge_gate_params());
669        assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
670        for entry in &entries {
671            assert_eq!(res.edge_shape_diagnostics[&entry.idx].cardinal_degree, 2);
672            assert_eq!(
673                res.edge_shape_diagnostics[&entry.idx].valid_adjacent_cell_count,
674                1
675            );
676        }
677    }
678
679    #[test]
680    fn step_aware_matches_global_on_uniform_grid() {
681        // On a uniform grid, per-corner step ≈ cell_size everywhere,
682        // so step-aware mode must agree with the default mode.
683        let entries = clean_grid(7, 7, 20.0);
684        let res_default = validate(&entries, 20.0, &ValidationParams::default());
685        let res_step_aware = validate(
686            &entries,
687            20.0,
688            &ValidationParams::default().with_step_aware(0.0),
689        );
690        assert_eq!(res_default.blacklist, res_step_aware.blacklist);
691    }
692
693    #[test]
694    fn step_aware_flags_perspective_foreshortened_outlier() {
695        // Build a grid whose right column has cell pitch ~10 px (half
696        // of the rest at 20 px). On a uniform-`cell_size = 20` global
697        // tolerance, a corner displaced by 4 px in the dense column
698        // sits at 4 / 20 = 0.20 of the global cell. With step-aware
699        // (local step ~10 px), the same residual sits at 4 / 10 = 0.40
700        // — the tighter per-corner threshold catches it where the
701        // global one would defer.
702        //
703        // Layout: 5x4 grid. Columns 0..3 at 20 px pitch; column 4 at
704        // 10 px from column 3.
705        let s = 20.0_f32;
706        let mut entries = Vec::new();
707        let mut idx = 0;
708        for j in 0..4_i32 {
709            for i in 0..4_i32 {
710                entries.push(entry(idx, i as f32 * s + 50.0, j as f32 * s + 50.0, i, j));
711                idx += 1;
712            }
713        }
714        // Column 4: half-pitch (foreshortened).
715        for j in 0..4_i32 {
716            entries.push(entry(
717                idx,
718                3.0 * s + 50.0 + 0.5 * s, // x = 110 (one half-step past column 3 at x = 110)
719                j as f32 * s + 50.0,
720                4,
721                j,
722            ));
723            idx += 1;
724        }
725        // Verify baseline: no outliers.
726        let baseline = validate(&entries, s, &ValidationParams::default());
727        assert!(baseline.blacklist.is_empty(), "{:?}", baseline.blacklist);
728
729        // Displace (4, 1) — the dense-column corner — by 3 px in y.
730        let target_idx = entries
731            .iter()
732            .find(|e| e.grid == (4, 1))
733            .map(|e| e.idx)
734            .expect("(4, 1) present");
735        for e in entries.iter_mut() {
736            if e.idx == target_idx {
737                e.pixel.y += 3.0;
738            }
739        }
740
741        let global_res = validate(&entries, s, &ValidationParams::default());
742        let step_aware_res = validate(
743            &entries,
744            s,
745            &ValidationParams::default().with_step_aware(0.0),
746        );
747
748        assert!(
749            step_aware_res.blacklist.contains(&target_idx)
750                || !global_res.blacklist.contains(&target_idx),
751            "step-aware should be at least as sensitive: global={:?} step-aware={:?}",
752            global_res.blacklist,
753            step_aware_res.blacklist
754        );
755    }
756
757    #[test]
758    fn step_deviation_flag_fires_on_off_scale_corner() {
759        let s = 20.0_f32;
760        let mut entries = clean_grid(5, 5, s);
761        let new_idx = entries.len();
762        entries.push(entry(
763            new_idx,
764            4.0 * s + 0.5 * s + 50.0,
765            2.0 * s + 50.0,
766            5,
767            2,
768        ));
769        entries[new_idx].pixel.y += 4.0;
770
771        let res = validate(
772            &entries,
773            s,
774            &ValidationParams::default().with_step_aware(0.5),
775        );
776        assert!(
777            res.blacklist.contains(&new_idx),
778            "expected new corner {new_idx} blacklisted: {:?}",
779            res.blacklist
780        );
781    }
782
783    #[test]
784    fn local_step_per_corner_central_diff() {
785        // Verify the helper produces central-difference values when
786        // both neighbours are present, and one-sided otherwise.
787        let entries = [
788            entry(0, 0.0, 0.0, 0, 0),
789            entry(1, 10.0, 0.0, 1, 0),
790            entry(2, 30.0, 0.0, 2, 0), // i-step at (1, 0): central = (30 - 0)/2 = 15
791            entry(3, 30.0, 20.0, 2, 1), // j-step at (2, 0): one-sided forward = 20
792        ];
793        let by_idx: HashMap<usize, &LabelledEntry> = entries.iter().map(|e| (e.idx, e)).collect();
794        let by_grid: HashMap<(i32, i32), usize> = entries.iter().map(|e| (e.grid, e.idx)).collect();
795        let steps = step::local_step_per_corner(&by_idx, &by_grid);
796
797        // (1, 0): central i-step = 15; no j neighbours → step = 15.
798        assert!((steps[&1] - 15.0).abs() < 1e-4, "got {}", steps[&1]);
799        // (2, 0): one-sided i-step backward = 20; j-step forward = 20 → mean = 20.
800        assert!((steps[&2] - 20.0).abs() < 1e-4, "got {}", steps[&2]);
801    }
802}