use std::collections::HashMap;
use nalgebra::{Point2, Projective2};
use crate::lattice::{Coord, GridDimensions, LatticeKind};
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct GridEntry {
pub coord: Coord,
pub source_index: usize,
pub image_position: Point2<f32>,
pub residual_px: Option<f32>,
}
impl GridEntry {
pub fn new(
coord: Coord,
source_index: usize,
image_position: Point2<f32>,
residual_px: Option<f32>,
) -> Self {
Self {
coord,
source_index,
image_position,
residual_px,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct LabelledGrid {
pub lattice: LatticeKind,
pub entries: Vec<GridEntry>,
pub bbox: Option<(Coord, Coord)>,
pub dimensions: Option<GridDimensions>,
}
impl LabelledGrid {
pub fn new(
lattice: LatticeKind,
entries: Vec<GridEntry>,
dimensions: Option<GridDimensions>,
) -> Self {
let bbox = bbox_for_entries(&entries);
Self {
lattice,
entries,
bbox,
dimensions,
}
}
pub fn find(&self, source_index: usize) -> Option<&GridEntry> {
self.entries.iter().find(|e| e.source_index == source_index)
}
pub fn normalize(&mut self) {
rebase_entries_to_origin(&mut self.entries);
let swapped = canonicalize_to_image_axes(&mut self.entries);
if swapped {
if let Some(dims) = self.dimensions.as_mut() {
std::mem::swap(&mut dims.width, &mut dims.height);
}
}
self.entries.sort_by_key(|e| (e.coord.v, e.coord.u));
self.bbox = bbox_for_entries(&self.entries);
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct ResidualSummary {
pub count: usize,
pub mean_px: f32,
pub max_px: f32,
}
impl ResidualSummary {
pub fn new(count: usize, mean_px: f32, max_px: f32) -> Self {
Self {
count,
mean_px,
max_px,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct LatticeFit {
pub model_to_image: Projective2<f32>,
pub residuals: ResidualSummary,
}
impl LatticeFit {
pub fn new(model_to_image: Projective2<f32>, residuals: ResidualSummary) -> Self {
Self {
model_to_image,
residuals,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RejectionReason {
ResidualTooHigh,
Unlabelled,
ValidationDropped,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct RejectedFeature {
pub source_index: usize,
pub coord: Option<Coord>,
pub residual_px: Option<f32>,
pub reason: RejectionReason,
}
impl RejectedFeature {
pub fn new(
source_index: usize,
coord: Option<Coord>,
residual_px: Option<f32>,
reason: RejectionReason,
) -> Self {
Self {
source_index,
coord,
residual_px,
reason,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct GridSolution {
pub grid: LabelledGrid,
pub fit: Option<LatticeFit>,
pub rejected: Vec<RejectedFeature>,
}
impl GridSolution {
pub fn new(
grid: LabelledGrid,
fit: Option<LatticeFit>,
rejected: Vec<RejectedFeature>,
) -> Self {
Self {
grid,
fit,
rejected,
}
}
pub fn rejected_for(&self, source_index: usize) -> Option<&RejectedFeature> {
self.rejected
.iter()
.find(|r| r.source_index == source_index)
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ConsistencyReport {
pub passed: bool,
pub solution: GridSolution,
}
impl ConsistencyReport {
pub fn new(passed: bool, solution: GridSolution) -> Self {
Self { passed, solution }
}
pub fn max_residual_px(&self) -> Option<f32> {
Some(self.solution.fit.as_ref()?.residuals.max_px)
}
}
fn rebase_entries_to_origin(entries: &mut [GridEntry]) {
if entries.is_empty() {
return;
}
let (min_u, min_v) = entries.iter().fold((i32::MAX, i32::MAX), |(a, b), e| {
(a.min(e.coord.u), b.min(e.coord.v))
});
if min_u != 0 || min_v != 0 {
for e in entries.iter_mut() {
e.coord.u -= min_u;
e.coord.v -= min_v;
}
}
}
fn canonicalize_to_image_axes(entries: &mut [GridEntry]) -> bool {
if entries.len() < 2 {
return false;
}
let pos_by_uv: HashMap<(i32, i32), (f32, f32)> = entries
.iter()
.map(|e| {
(
(e.coord.u, e.coord.v),
(e.image_position.x, e.image_position.y),
)
})
.collect();
let mut keys: Vec<(i32, i32)> = pos_by_uv.keys().copied().collect();
keys.sort_unstable();
let mut vu_sum = (0.0_f32, 0.0_f32);
let mut vv_sum = (0.0_f32, 0.0_f32);
let mut vu_n = 0u32;
let mut vv_n = 0u32;
for &(u, v) in &keys {
let (x, y) = pos_by_uv[&(u, v)];
if let Some(&(xn, yn)) = pos_by_uv.get(&(u + 1, v)) {
vu_sum.0 += xn - x;
vu_sum.1 += yn - y;
vu_n += 1;
}
if let Some(&(xn, yn)) = pos_by_uv.get(&(u, v + 1)) {
vv_sum.0 += xn - x;
vv_sum.1 += yn - y;
vv_n += 1;
}
}
if vu_n == 0 || vv_n == 0 {
return false;
}
let vu = (vu_sum.0 / vu_n as f32, vu_sum.1 / vu_n as f32);
let vv = (vv_sum.0 / vv_n as f32, vv_sum.1 / vv_n as f32);
let swap = vu.0.abs() < vv.0.abs();
let new_vu = if swap { vv } else { vu };
let new_vv = if swap { vu } else { vv };
let flip_u = new_vu.0 < 0.0;
let flip_v = new_vv.1 < 0.0;
if !swap && !flip_u && !flip_v {
return false;
}
let mut umax = i32::MIN;
let mut vmax = i32::MIN;
for e in entries.iter() {
let (nu, nv) = if swap {
(e.coord.v, e.coord.u)
} else {
(e.coord.u, e.coord.v)
};
umax = umax.max(nu);
vmax = vmax.max(nv);
}
for e in entries.iter_mut() {
let (mut nu, mut nv) = if swap {
(e.coord.v, e.coord.u)
} else {
(e.coord.u, e.coord.v)
};
if flip_u {
nu = umax - nu;
}
if flip_v {
nv = vmax - nv;
}
e.coord.u = nu;
e.coord.v = nv;
}
swap
}
fn bbox_for_entries(entries: &[GridEntry]) -> Option<(Coord, Coord)> {
let first = entries.first()?;
let mut min = first.coord;
let mut max = first.coord;
for entry in &entries[1..] {
min.u = min.u.min(entry.coord.u);
min.v = min.v.min(entry.coord.v);
max.u = max.u.max(entry.coord.u);
max.v = max.v.max(entry.coord.v);
}
Some((min, max))
}
#[cfg(test)]
mod tests {
use nalgebra::{Point2, Projective2};
use super::*;
fn make_identity_fit() -> LatticeFit {
LatticeFit::new(
Projective2::identity(),
ResidualSummary::new(1, 0.5_f32, 1.0_f32),
)
}
#[test]
fn max_residual_px_none_when_fit_absent() {
let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
let solution = GridSolution::new(grid, None, vec![]);
let report = ConsistencyReport::new(true, solution);
assert_eq!(report.max_residual_px(), None);
}
#[test]
fn max_residual_px_some_when_fit_present() {
let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
let fit = make_identity_fit();
let solution = GridSolution::new(grid, Some(fit), vec![]);
let report = ConsistencyReport::new(true, solution);
assert_eq!(report.max_residual_px(), Some(1.0_f32));
}
#[test]
fn labelled_grid_find_present_and_absent() {
let entry = GridEntry::new(Coord::new(0, 0), 42, Point2::new(1.0_f32, 2.0), None);
let grid = LabelledGrid::new(LatticeKind::Square, vec![entry], None);
assert!(grid.find(42).is_some());
assert!(grid.find(99).is_none());
}
fn mk_entry(u: i32, v: i32, x: f32, y: f32) -> GridEntry {
GridEntry::new(Coord::new(u, v), 0, Point2::new(x, y), None)
}
fn coord_by_pos(grid: &LabelledGrid) -> HashMap<(i32, i32), (i32, i32)> {
grid.entries
.iter()
.map(|e| {
(
(e.image_position.x as i32, e.image_position.y as i32),
(e.coord.u, e.coord.v),
)
})
.collect()
}
#[test]
fn normalize_rebases_and_sorts_already_canonical() {
let entries = vec![
mk_entry(3, 5, 10.0, 10.0),
mk_entry(4, 5, 20.0, 10.0),
mk_entry(3, 6, 10.0, 20.0),
mk_entry(4, 6, 20.0, 20.0),
];
let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
grid.normalize();
let by_pos = coord_by_pos(&grid);
assert_eq!(by_pos[&(10, 10)], (0, 0));
assert_eq!(by_pos[&(20, 10)], (1, 0));
assert_eq!(by_pos[&(10, 20)], (0, 1));
assert_eq!(by_pos[&(20, 20)], (1, 1));
assert_eq!(grid.bbox, Some((Coord::new(0, 0), Coord::new(1, 1))));
let order: Vec<(i32, i32)> = grid
.entries
.iter()
.map(|e| (e.coord.u, e.coord.v))
.collect();
assert_eq!(order, vec![(0, 0), (1, 0), (0, 1), (1, 1)]);
}
#[test]
fn normalize_canonicalizes_rotated_axes() {
let entries = vec![
mk_entry(0, 0, 10.0, 10.0),
mk_entry(0, 1, 20.0, 10.0),
mk_entry(1, 0, 10.0, 20.0),
mk_entry(1, 1, 20.0, 20.0),
];
let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
grid.normalize();
let by_pos = coord_by_pos(&grid);
assert_eq!(
by_pos[&(10, 10)],
(0, 0),
"(0,0) must land at smallest (x,y)"
);
assert_eq!(by_pos[&(20, 10)], (1, 0), "+u must point +x");
assert_eq!(by_pos[&(10, 20)], (0, 1), "+v must point +y");
}
#[test]
fn normalize_transposes_dimensions_on_axis_swap() {
let entries = vec![
mk_entry(0, 0, 10.0, 10.0),
mk_entry(0, 1, 20.0, 10.0),
mk_entry(1, 0, 10.0, 20.0),
mk_entry(1, 1, 20.0, 20.0),
];
let mut grid = LabelledGrid::new(
LatticeKind::Square,
entries,
Some(GridDimensions::new(5, 3)),
);
grid.normalize();
assert_eq!(
grid.dimensions,
Some(GridDimensions::new(3, 5)),
"axis swap must transpose width/height"
);
}
#[test]
fn normalize_keeps_dimensions_when_axes_only_flip() {
let entries = vec![
mk_entry(0, 0, 20.0, 10.0),
mk_entry(1, 0, 10.0, 10.0),
mk_entry(0, 1, 20.0, 20.0),
mk_entry(1, 1, 10.0, 20.0),
];
let mut grid = LabelledGrid::new(
LatticeKind::Square,
entries,
Some(GridDimensions::new(5, 3)),
);
grid.normalize();
assert_eq!(
grid.dimensions,
Some(GridDimensions::new(5, 3)),
"a sign flip without a transpose must not touch dimensions"
);
}
#[test]
fn grid_solution_rejected_for_present_and_absent() {
let rejected =
RejectedFeature::new(5, None, Some(3.0_f32), RejectionReason::ResidualTooHigh);
let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
let solution = GridSolution::new(grid, None, vec![rejected]);
assert!(solution.rejected_for(5).is_some());
assert!(solution.rejected_for(0).is_none());
}
}