mod edge_shape;
mod lines;
mod local_h;
mod step;
pub mod wrong_label_filters;
use nalgebra::Point2;
use std::collections::{HashMap, HashSet};
#[non_exhaustive]
#[derive(Clone, Copy, Debug)]
pub struct ValidationParams {
pub line_tol_rel: f32,
pub line_min_members: usize,
pub local_h_tol_rel: f32,
pub use_step_aware: bool,
pub step_deviation_thresh_rel: f32,
pub edge_shape: Option<EdgeShapeParams>,
}
impl Default for ValidationParams {
fn default() -> Self {
Self {
line_tol_rel: 0.15,
line_min_members: 3,
local_h_tol_rel: 0.20,
use_step_aware: false,
step_deviation_thresh_rel: 0.0,
edge_shape: None,
}
}
}
impl ValidationParams {
pub fn new(line_tol_rel: f32, line_min_members: usize, local_h_tol_rel: f32) -> Self {
Self {
line_tol_rel,
line_min_members,
local_h_tol_rel,
use_step_aware: false,
step_deviation_thresh_rel: 0.0,
edge_shape: None,
}
}
pub fn with_step_aware(mut self, deviation_thresh_rel: f32) -> Self {
self.use_step_aware = true;
self.step_deviation_thresh_rel = deviation_thresh_rel;
self
}
pub fn with_line_tol_rel(mut self, value: f32) -> Self {
self.line_tol_rel = value;
self
}
pub fn with_local_h_tol_rel(mut self, value: f32) -> Self {
self.local_h_tol_rel = value;
self
}
pub fn with_edge_length_band_rel(self, _value: f32) -> Self {
self
}
pub fn with_edge_shape_gate(mut self, edge_shape: EdgeShapeParams) -> Self {
self.edge_shape = Some(edge_shape);
self
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug)]
pub struct EdgeShapeParams {
pub min_cardinal_degree: u8,
pub continuation_angle_tol_deg: f32,
pub continuation_length_ratio_max: f32,
pub cell_opposite_angle_tol_deg: f32,
pub cell_opposite_length_ratio_max: f32,
}
impl Default for EdgeShapeParams {
fn default() -> Self {
Self {
min_cardinal_degree: 2,
continuation_angle_tol_deg: 8.0,
continuation_length_ratio_max: 1.18,
cell_opposite_angle_tol_deg: 8.0,
cell_opposite_length_ratio_max: 1.10,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct EdgeShapeDiagnostic {
pub cardinal_degree: u8,
pub is_bbox_boundary: bool,
pub max_continuation_angle_deg: Option<f32>,
pub max_continuation_length_ratio: Option<f32>,
pub adjacent_cell_count: u8,
pub valid_adjacent_cell_count: u8,
pub max_cell_opposite_angle_deg: Option<f32>,
pub max_cell_opposite_length_ratio: Option<f32>,
}
#[derive(Clone, Copy, Debug)]
pub struct LabelledEntry {
pub idx: usize,
pub pixel: Point2<f32>,
pub grid: (i32, i32),
}
#[derive(Debug, Default)]
pub struct ValidationResult {
pub blacklist: HashSet<usize>,
pub local_h_residuals: HashMap<usize, f32>,
pub edge_shape_diagnostics: HashMap<usize, EdgeShapeDiagnostic>,
pub edge_shape_reasons: HashMap<usize, &'static str>,
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
level = "info",
skip_all,
fields(num_labelled = entries.len(), cell_size = cell_size),
)
)]
pub fn validate(
entries: &[LabelledEntry],
cell_size: f32,
params: &ValidationParams,
) -> ValidationResult {
let mut sorted_entries: Vec<&LabelledEntry> = entries.iter().collect();
sorted_entries.sort_unstable_by_key(|e| (e.grid, e.idx));
let by_idx: HashMap<usize, &LabelledEntry> =
sorted_entries.iter().map(|&e| (e.idx, e)).collect();
let by_grid: HashMap<(i32, i32), usize> = entries.iter().map(|e| (e.grid, e.idx)).collect();
let per_corner_step = if params.use_step_aware {
step::local_step_per_corner(&by_idx, &by_grid)
} else {
HashMap::new()
};
let scale_at = |idx: usize| -> f32 {
if params.use_step_aware {
per_corner_step.get(&idx).copied().unwrap_or(cell_size)
} else {
cell_size
}
};
let line_flags = lines::line_collinearity_flags(&by_idx, &by_grid, params, &scale_at);
let mut entry_order: Vec<usize> = (0..entries.len()).collect();
entry_order.sort_unstable_by_key(|&k| (entries[k].grid, entries[k].idx));
let mut residuals: HashMap<usize, f32> = HashMap::new();
let mut local_h_flagged: HashMap<usize, f32> = HashMap::new();
let mut local_h_high: HashMap<usize, f32> = HashMap::new();
for &k in &entry_order {
let entry = &entries[k];
let base = local_h::pick_local_h_base(&by_grid, entry.idx, entry.grid);
if base.len() < 4 {
continue;
}
let Some(resid) = local_h::local_h_residual(&by_idx, entry.idx, entry.grid, &base) else {
continue;
};
residuals.insert(entry.idx, resid);
let scale = scale_at(entry.idx);
let local_h_tol_px = params.local_h_tol_rel * scale;
if resid > local_h_tol_px {
local_h_flagged.insert(entry.idx, resid);
if resid > 2.0 * local_h_tol_px {
local_h_high.insert(entry.idx, resid);
}
}
}
let step_dev_flags = if params.use_step_aware && params.step_deviation_thresh_rel > 0.0 {
step::flag_step_deviations(&per_corner_step, params.step_deviation_thresh_rel)
} else {
HashSet::new()
};
let mut blacklist: HashSet<usize> = HashSet::new();
for (&idx, &count) in &line_flags {
if count >= 2 {
blacklist.insert(idx);
}
}
for &idx in local_h_high.keys() {
if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
blacklist.insert(idx);
}
}
let mut local_h_flagged_order: Vec<usize> = local_h_flagged.keys().copied().collect();
local_h_flagged_order.sort_unstable();
for idx in local_h_flagged_order {
if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
continue;
}
if blacklist.contains(&idx) {
continue;
}
let Some(entry) = by_idx.get(&idx) else {
continue;
};
let base = local_h::pick_local_h_base(&by_grid, idx, entry.grid);
let mut worst: Option<(usize, u32)> = None;
for &(base_idx, _) in &base {
if let Some(&flags) = line_flags.get(&base_idx) {
if flags >= 1 && worst.map(|w| flags > w.1).unwrap_or(true) {
worst = Some((base_idx, flags));
}
}
}
if let Some((base_idx, _)) = worst {
blacklist.insert(base_idx);
}
}
for &idx in &step_dev_flags {
if line_flags.get(&idx).copied().unwrap_or(0) >= 1 {
blacklist.insert(idx);
}
}
let (edge_shape_diagnostics, edge_shape_reasons) =
if let Some(edge_shape_params) = params.edge_shape {
let (diagnostics, reasons) =
edge_shape::evaluate_edge_shape(&by_idx, &by_grid, edge_shape_params);
blacklist.extend(reasons.keys().copied());
(diagnostics, reasons)
} else {
(HashMap::new(), HashMap::new())
};
ValidationResult {
blacklist,
local_h_residuals: residuals,
edge_shape_diagnostics,
edge_shape_reasons,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(idx: usize, x: f32, y: f32, i: i32, j: i32) -> LabelledEntry {
LabelledEntry {
idx,
pixel: Point2::new(x, y),
grid: (i, j),
}
}
fn clean_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
let mut out = Vec::new();
let mut idx = 0;
for j in 0..rows {
for i in 0..cols {
out.push(entry(idx, i as f32 * s + 50.0, j as f32 * s + 50.0, i, j));
idx += 1;
}
}
out
}
fn edge_gate_params() -> ValidationParams {
ValidationParams::new(999.0, 99, 999.0).with_edge_shape_gate(EdgeShapeParams::default())
}
fn mild_perspective_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
let mut out = Vec::new();
let mut idx = 0;
for j in 0..rows {
for i in 0..cols {
let u = i as f32;
let v = j as f32;
let denom = 1.0 + 0.01 * u + 0.006 * v;
let x = 50.0 + (s * (u + 0.08 * v)) / denom;
let y = 50.0 + (s * (v + 0.04 * u)) / denom;
out.push(entry(idx, x, y, i, j));
idx += 1;
}
}
out
}
fn mild_radial_grid(rows: i32, cols: i32, s: f32) -> Vec<LabelledEntry> {
let mut out = Vec::new();
let mut idx = 0;
let cx = (cols - 1) as f32 * 0.5;
let cy = (rows - 1) as f32 * 0.5;
for j in 0..rows {
for i in 0..cols {
let u = i as f32 - cx;
let v = j as f32 - cy;
let r2 = u * u + v * v;
let k = 1.0 + 0.006 * r2;
let x = 150.0 + s * u * k;
let y = 150.0 + s * v * k;
out.push(entry(idx, x, y, i, j));
idx += 1;
}
}
out
}
#[test]
fn clean_grid_empty_blacklist() {
let entries = clean_grid(7, 7, 20.0);
let res = validate(&entries, 20.0, &ValidationParams::default());
assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
}
#[test]
fn displaced_interior_is_blacklisted() {
let mut entries = clean_grid(7, 7, 20.0);
let target = entries
.iter_mut()
.find(|e| e.grid == (3, 3))
.expect("(3,3) present");
target.pixel.x += 6.0;
target.pixel.y += 6.0;
let target_idx = target.idx;
let res = validate(&entries, 20.0, &ValidationParams::default());
assert!(
res.blacklist.contains(&target_idx),
"expected {target_idx} blacklisted, got {:?}",
res.blacklist
);
}
#[test]
fn too_few_members_per_line_is_ignored() {
let entries = vec![entry(0, 0.0, 0.0, 0, 0), entry(1, 20.0, 0.0, 1, 0)];
let res = validate(&entries, 20.0, &ValidationParams::default());
assert!(res.blacklist.is_empty());
}
#[test]
fn edge_shape_clean_grid_passes() {
let entries = clean_grid(7, 7, 20.0);
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
}
#[test]
fn edge_shape_mild_perspective_grid_passes() {
let entries = mild_perspective_grid(7, 7, 20.0);
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
}
#[test]
fn edge_shape_mild_radial_grid_passes() {
let entries = mild_radial_grid(7, 7, 20.0);
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
}
#[test]
fn edge_shape_rejects_isolated_point() {
let mut entries = clean_grid(2, 2, 20.0);
let isolated_idx = entries.len();
entries.push(entry(isolated_idx, 150.0, 150.0, 5, 5));
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(
res.blacklist.contains(&isolated_idx),
"blacklist={:?}",
res.blacklist
);
assert_eq!(
res.edge_shape_reasons.get(&isolated_idx).copied(),
Some("low-cardinal-degree")
);
}
#[test]
fn edge_shape_rejects_degree_one_dangling_point() {
let mut entries = clean_grid(2, 2, 20.0);
let dangling_idx = entries.len();
entries.push(entry(dangling_idx, 90.0, 50.0, 2, 0));
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(
res.blacklist.contains(&dangling_idx),
"blacklist={:?}",
res.blacklist
);
assert_eq!(res.edge_shape_diagnostics[&dangling_idx].cardinal_degree, 1);
}
#[test]
fn edge_shape_rejects_bad_continuation_across_vertex() {
let mut entries = clean_grid(3, 3, 20.0);
let target = entries
.iter_mut()
.find(|e| e.grid == (1, 1))
.expect("(1,1) present");
target.pixel.x += 8.0;
let target_idx = target.idx;
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(
res.blacklist.contains(&target_idx),
"blacklist={:?} diagnostics={:?}",
res.blacklist,
res.edge_shape_diagnostics.get(&target_idx)
);
assert_eq!(
res.edge_shape_reasons.get(&target_idx).copied(),
Some("bad-continuation")
);
}
#[test]
fn edge_shape_rejects_corner_with_no_valid_adjacent_cell() {
let mut entries = clean_grid(2, 2, 20.0);
let target = entries
.iter_mut()
.find(|e| e.grid == (1, 1))
.expect("(1,1) present");
target.pixel.x += 8.0;
let target_idx = target.idx;
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(
res.blacklist.contains(&target_idx),
"blacklist={:?} diagnostics={:?}",
res.blacklist,
res.edge_shape_diagnostics.get(&target_idx)
);
assert_eq!(
res.edge_shape_diagnostics[&target_idx].adjacent_cell_count,
1
);
assert_eq!(
res.edge_shape_reasons.get(&target_idx).copied(),
Some("no-valid-adjacent-cell")
);
}
#[test]
fn edge_shape_complete_two_by_two_cell_keeps_degree_two_corners() {
let entries = clean_grid(2, 2, 20.0);
let res = validate(&entries, 20.0, &edge_gate_params());
assert!(res.blacklist.is_empty(), "{:?}", res.blacklist);
for entry in &entries {
assert_eq!(res.edge_shape_diagnostics[&entry.idx].cardinal_degree, 2);
assert_eq!(
res.edge_shape_diagnostics[&entry.idx].valid_adjacent_cell_count,
1
);
}
}
#[test]
fn step_aware_matches_global_on_uniform_grid() {
let entries = clean_grid(7, 7, 20.0);
let res_default = validate(&entries, 20.0, &ValidationParams::default());
let res_step_aware = validate(
&entries,
20.0,
&ValidationParams::default().with_step_aware(0.0),
);
assert_eq!(res_default.blacklist, res_step_aware.blacklist);
}
#[test]
fn step_aware_flags_perspective_foreshortened_outlier() {
let s = 20.0_f32;
let mut entries = Vec::new();
let mut idx = 0;
for j in 0..4_i32 {
for i in 0..4_i32 {
entries.push(entry(idx, i as f32 * s + 50.0, j as f32 * s + 50.0, i, j));
idx += 1;
}
}
for j in 0..4_i32 {
entries.push(entry(
idx,
3.0 * s + 50.0 + 0.5 * s, j as f32 * s + 50.0,
4,
j,
));
idx += 1;
}
let baseline = validate(&entries, s, &ValidationParams::default());
assert!(baseline.blacklist.is_empty(), "{:?}", baseline.blacklist);
let target_idx = entries
.iter()
.find(|e| e.grid == (4, 1))
.map(|e| e.idx)
.expect("(4, 1) present");
for e in entries.iter_mut() {
if e.idx == target_idx {
e.pixel.y += 3.0;
}
}
let global_res = validate(&entries, s, &ValidationParams::default());
let step_aware_res = validate(
&entries,
s,
&ValidationParams::default().with_step_aware(0.0),
);
assert!(
step_aware_res.blacklist.contains(&target_idx)
|| !global_res.blacklist.contains(&target_idx),
"step-aware should be at least as sensitive: global={:?} step-aware={:?}",
global_res.blacklist,
step_aware_res.blacklist
);
}
#[test]
fn step_deviation_flag_fires_on_off_scale_corner() {
let s = 20.0_f32;
let mut entries = clean_grid(5, 5, s);
let new_idx = entries.len();
entries.push(entry(
new_idx,
4.0 * s + 0.5 * s + 50.0,
2.0 * s + 50.0,
5,
2,
));
entries[new_idx].pixel.y += 4.0;
let res = validate(
&entries,
s,
&ValidationParams::default().with_step_aware(0.5),
);
assert!(
res.blacklist.contains(&new_idx),
"expected new corner {new_idx} blacklisted: {:?}",
res.blacklist
);
}
#[test]
fn local_step_per_corner_central_diff() {
let entries = [
entry(0, 0.0, 0.0, 0, 0),
entry(1, 10.0, 0.0, 1, 0),
entry(2, 30.0, 0.0, 2, 0), entry(3, 30.0, 20.0, 2, 1), ];
let by_idx: HashMap<usize, &LabelledEntry> = entries.iter().map(|e| (e.idx, e)).collect();
let by_grid: HashMap<(i32, i32), usize> = entries.iter().map(|e| (e.grid, e.idx)).collect();
let steps = step::local_step_per_corner(&by_idx, &by_grid);
assert!((steps[&1] - 15.0).abs() < 1e-4, "got {}", steps[&1]);
assert!((steps[&2] - 20.0).abs() < 1e-4, "got {}", steps[&2]);
}
}