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