mod lines;
mod local_h;
mod step;
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,
}
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,
}
}
}
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,
}
}
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
}
}
#[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>,
}
#[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 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 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 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 entry in entries {
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, &by_grid)
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);
}
}
for &idx in local_h_flagged.keys() {
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);
}
}
ValidationResult {
blacklist,
local_h_residuals: residuals,
}
}
#[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
}
#[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 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]);
}
}