1use std::collections::HashMap;
7
8use nalgebra::{Point2, Projective2};
9
10use crate::lattice::{Coord, GridDimensions, LatticeKind};
11
12#[derive(Clone, Copy, Debug, PartialEq)]
14#[non_exhaustive]
15pub struct GridEntry {
16 pub coord: Coord,
18 pub source_index: usize,
20 pub image_position: Point2<f32>,
22 pub residual_px: Option<f32>,
24}
25
26impl GridEntry {
27 pub fn new(
29 coord: Coord,
30 source_index: usize,
31 image_position: Point2<f32>,
32 residual_px: Option<f32>,
33 ) -> Self {
34 Self {
35 coord,
36 source_index,
37 image_position,
38 residual_px,
39 }
40 }
41}
42
43#[derive(Clone, Debug, PartialEq)]
45#[non_exhaustive]
46pub struct LabelledGrid {
47 pub lattice: LatticeKind,
49 pub entries: Vec<GridEntry>,
51 pub bbox: Option<(Coord, Coord)>,
53 pub dimensions: Option<GridDimensions>,
55}
56
57impl LabelledGrid {
58 pub fn new(
60 lattice: LatticeKind,
61 entries: Vec<GridEntry>,
62 dimensions: Option<GridDimensions>,
63 ) -> Self {
64 let bbox = bbox_for_entries(&entries);
65 Self {
66 lattice,
67 entries,
68 bbox,
69 dimensions,
70 }
71 }
72
73 pub fn find(&self, source_index: usize) -> Option<&GridEntry> {
75 self.entries.iter().find(|e| e.source_index == source_index)
76 }
77
78 pub fn normalize(&mut self) {
104 rebase_entries_to_origin(&mut self.entries);
105 let swapped = canonicalize_to_image_axes(&mut self.entries);
106 if swapped {
107 if let Some(dims) = self.dimensions.as_mut() {
111 std::mem::swap(&mut dims.width, &mut dims.height);
112 }
113 }
114 self.entries.sort_by_key(|e| (e.coord.v, e.coord.u));
115 self.bbox = bbox_for_entries(&self.entries);
116 }
117}
118
119#[derive(Clone, Copy, Debug, PartialEq)]
121#[non_exhaustive]
122pub struct ResidualSummary {
123 pub count: usize,
125 pub mean_px: f32,
127 pub max_px: f32,
129}
130
131impl ResidualSummary {
132 pub fn new(count: usize, mean_px: f32, max_px: f32) -> Self {
134 Self {
135 count,
136 mean_px,
137 max_px,
138 }
139 }
140}
141
142#[derive(Clone, Debug, PartialEq)]
144#[non_exhaustive]
145pub struct LatticeFit {
146 pub model_to_image: Projective2<f32>,
148 pub residuals: ResidualSummary,
150}
151
152impl LatticeFit {
153 pub fn new(model_to_image: Projective2<f32>, residuals: ResidualSummary) -> Self {
155 Self {
156 model_to_image,
157 residuals,
158 }
159 }
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
164#[non_exhaustive]
165pub enum RejectionReason {
166 ResidualTooHigh,
168 Unlabelled,
171 ValidationDropped,
175}
176
177#[derive(Clone, Copy, Debug, PartialEq)]
179#[non_exhaustive]
180pub struct RejectedFeature {
181 pub source_index: usize,
183 pub coord: Option<Coord>,
185 pub residual_px: Option<f32>,
187 pub reason: RejectionReason,
189}
190
191impl RejectedFeature {
192 pub fn new(
194 source_index: usize,
195 coord: Option<Coord>,
196 residual_px: Option<f32>,
197 reason: RejectionReason,
198 ) -> Self {
199 Self {
200 source_index,
201 coord,
202 residual_px,
203 reason,
204 }
205 }
206}
207
208#[derive(Clone, Debug, PartialEq)]
210#[non_exhaustive]
211pub struct GridSolution {
212 pub grid: LabelledGrid,
214 pub fit: Option<LatticeFit>,
216 pub rejected: Vec<RejectedFeature>,
218}
219
220impl GridSolution {
221 pub fn new(
223 grid: LabelledGrid,
224 fit: Option<LatticeFit>,
225 rejected: Vec<RejectedFeature>,
226 ) -> Self {
227 Self {
228 grid,
229 fit,
230 rejected,
231 }
232 }
233
234 pub fn rejected_for(&self, source_index: usize) -> Option<&RejectedFeature> {
236 self.rejected
237 .iter()
238 .find(|r| r.source_index == source_index)
239 }
240}
241
242#[derive(Clone, Debug, PartialEq)]
244#[non_exhaustive]
245pub struct ConsistencyReport {
246 pub passed: bool,
248 pub solution: GridSolution,
250}
251
252impl ConsistencyReport {
253 pub fn new(passed: bool, solution: GridSolution) -> Self {
255 Self { passed, solution }
256 }
257
258 pub fn max_residual_px(&self) -> Option<f32> {
261 Some(self.solution.fit.as_ref()?.residuals.max_px)
262 }
263}
264
265fn rebase_entries_to_origin(entries: &mut [GridEntry]) {
267 if entries.is_empty() {
268 return;
269 }
270 let (min_u, min_v) = entries.iter().fold((i32::MAX, i32::MAX), |(a, b), e| {
271 (a.min(e.coord.u), b.min(e.coord.v))
272 });
273 if min_u != 0 || min_v != 0 {
274 for e in entries.iter_mut() {
275 e.coord.u -= min_u;
276 e.coord.v -= min_v;
277 }
278 }
279}
280
281fn canonicalize_to_image_axes(entries: &mut [GridEntry]) -> bool {
295 if entries.len() < 2 {
296 return false;
297 }
298 let pos_by_uv: HashMap<(i32, i32), (f32, f32)> = entries
299 .iter()
300 .map(|e| {
301 (
302 (e.coord.u, e.coord.v),
303 (e.image_position.x, e.image_position.y),
304 )
305 })
306 .collect();
307
308 let mut keys: Vec<(i32, i32)> = pos_by_uv.keys().copied().collect();
309 keys.sort_unstable();
310 let mut vu_sum = (0.0_f32, 0.0_f32);
311 let mut vv_sum = (0.0_f32, 0.0_f32);
312 let mut vu_n = 0u32;
313 let mut vv_n = 0u32;
314 for &(u, v) in &keys {
315 let (x, y) = pos_by_uv[&(u, v)];
316 if let Some(&(xn, yn)) = pos_by_uv.get(&(u + 1, v)) {
317 vu_sum.0 += xn - x;
318 vu_sum.1 += yn - y;
319 vu_n += 1;
320 }
321 if let Some(&(xn, yn)) = pos_by_uv.get(&(u, v + 1)) {
322 vv_sum.0 += xn - x;
323 vv_sum.1 += yn - y;
324 vv_n += 1;
325 }
326 }
327 if vu_n == 0 || vv_n == 0 {
328 return false;
329 }
330 let vu = (vu_sum.0 / vu_n as f32, vu_sum.1 / vu_n as f32);
331 let vv = (vv_sum.0 / vv_n as f32, vv_sum.1 / vv_n as f32);
332
333 let swap = vu.0.abs() < vv.0.abs();
335 let new_vu = if swap { vv } else { vu };
336 let new_vv = if swap { vu } else { vv };
337 let flip_u = new_vu.0 < 0.0;
338 let flip_v = new_vv.1 < 0.0;
339
340 if !swap && !flip_u && !flip_v {
341 return false;
342 }
343
344 let mut umax = i32::MIN;
346 let mut vmax = i32::MIN;
347 for e in entries.iter() {
348 let (nu, nv) = if swap {
349 (e.coord.v, e.coord.u)
350 } else {
351 (e.coord.u, e.coord.v)
352 };
353 umax = umax.max(nu);
354 vmax = vmax.max(nv);
355 }
356
357 for e in entries.iter_mut() {
358 let (mut nu, mut nv) = if swap {
359 (e.coord.v, e.coord.u)
360 } else {
361 (e.coord.u, e.coord.v)
362 };
363 if flip_u {
364 nu = umax - nu;
365 }
366 if flip_v {
367 nv = vmax - nv;
368 }
369 e.coord.u = nu;
370 e.coord.v = nv;
371 }
372
373 swap
374}
375
376fn bbox_for_entries(entries: &[GridEntry]) -> Option<(Coord, Coord)> {
377 let first = entries.first()?;
378 let mut min = first.coord;
379 let mut max = first.coord;
380 for entry in &entries[1..] {
381 min.u = min.u.min(entry.coord.u);
382 min.v = min.v.min(entry.coord.v);
383 max.u = max.u.max(entry.coord.u);
384 max.v = max.v.max(entry.coord.v);
385 }
386 Some((min, max))
387}
388
389#[cfg(test)]
390mod tests {
391 use nalgebra::{Point2, Projective2};
392
393 use super::*;
394
395 fn make_identity_fit() -> LatticeFit {
396 LatticeFit::new(
397 Projective2::identity(),
398 ResidualSummary::new(1, 0.5_f32, 1.0_f32),
399 )
400 }
401
402 #[test]
403 fn max_residual_px_none_when_fit_absent() {
404 let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
405 let solution = GridSolution::new(grid, None, vec![]);
406 let report = ConsistencyReport::new(true, solution);
407 assert_eq!(report.max_residual_px(), None);
408 }
409
410 #[test]
411 fn max_residual_px_some_when_fit_present() {
412 let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
413 let fit = make_identity_fit();
414 let solution = GridSolution::new(grid, Some(fit), vec![]);
415 let report = ConsistencyReport::new(true, solution);
416 assert_eq!(report.max_residual_px(), Some(1.0_f32));
417 }
418
419 #[test]
420 fn labelled_grid_find_present_and_absent() {
421 let entry = GridEntry::new(Coord::new(0, 0), 42, Point2::new(1.0_f32, 2.0), None);
422 let grid = LabelledGrid::new(LatticeKind::Square, vec![entry], None);
423 assert!(grid.find(42).is_some());
424 assert!(grid.find(99).is_none());
425 }
426
427 fn mk_entry(u: i32, v: i32, x: f32, y: f32) -> GridEntry {
428 GridEntry::new(Coord::new(u, v), 0, Point2::new(x, y), None)
429 }
430
431 fn coord_by_pos(grid: &LabelledGrid) -> HashMap<(i32, i32), (i32, i32)> {
432 grid.entries
433 .iter()
434 .map(|e| {
435 (
436 (e.image_position.x as i32, e.image_position.y as i32),
437 (e.coord.u, e.coord.v),
438 )
439 })
440 .collect()
441 }
442
443 #[test]
444 fn normalize_rebases_and_sorts_already_canonical() {
445 let entries = vec![
447 mk_entry(3, 5, 10.0, 10.0),
448 mk_entry(4, 5, 20.0, 10.0),
449 mk_entry(3, 6, 10.0, 20.0),
450 mk_entry(4, 6, 20.0, 20.0),
451 ];
452 let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
453 grid.normalize();
454 let by_pos = coord_by_pos(&grid);
455 assert_eq!(by_pos[&(10, 10)], (0, 0));
456 assert_eq!(by_pos[&(20, 10)], (1, 0));
457 assert_eq!(by_pos[&(10, 20)], (0, 1));
458 assert_eq!(by_pos[&(20, 20)], (1, 1));
459 assert_eq!(grid.bbox, Some((Coord::new(0, 0), Coord::new(1, 1))));
460 let order: Vec<(i32, i32)> = grid
462 .entries
463 .iter()
464 .map(|e| (e.coord.u, e.coord.v))
465 .collect();
466 assert_eq!(order, vec![(0, 0), (1, 0), (0, 1), (1, 1)]);
467 }
468
469 #[test]
470 fn normalize_canonicalizes_rotated_axes() {
471 let entries = vec![
475 mk_entry(0, 0, 10.0, 10.0),
476 mk_entry(0, 1, 20.0, 10.0),
477 mk_entry(1, 0, 10.0, 20.0),
478 mk_entry(1, 1, 20.0, 20.0),
479 ];
480 let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
481 grid.normalize();
482 let by_pos = coord_by_pos(&grid);
483 assert_eq!(
484 by_pos[&(10, 10)],
485 (0, 0),
486 "(0,0) must land at smallest (x,y)"
487 );
488 assert_eq!(by_pos[&(20, 10)], (1, 0), "+u must point +x");
489 assert_eq!(by_pos[&(10, 20)], (0, 1), "+v must point +y");
490 }
491
492 #[test]
493 fn normalize_transposes_dimensions_on_axis_swap() {
494 let entries = vec![
499 mk_entry(0, 0, 10.0, 10.0),
500 mk_entry(0, 1, 20.0, 10.0),
501 mk_entry(1, 0, 10.0, 20.0),
502 mk_entry(1, 1, 20.0, 20.0),
503 ];
504 let mut grid = LabelledGrid::new(
505 LatticeKind::Square,
506 entries,
507 Some(GridDimensions::new(5, 3)),
508 );
509 grid.normalize();
510 assert_eq!(
511 grid.dimensions,
512 Some(GridDimensions::new(3, 5)),
513 "axis swap must transpose width/height"
514 );
515 }
516
517 #[test]
518 fn normalize_keeps_dimensions_when_axes_only_flip() {
519 let entries = vec![
522 mk_entry(0, 0, 20.0, 10.0),
523 mk_entry(1, 0, 10.0, 10.0),
524 mk_entry(0, 1, 20.0, 20.0),
525 mk_entry(1, 1, 10.0, 20.0),
526 ];
527 let mut grid = LabelledGrid::new(
528 LatticeKind::Square,
529 entries,
530 Some(GridDimensions::new(5, 3)),
531 );
532 grid.normalize();
533 assert_eq!(
534 grid.dimensions,
535 Some(GridDimensions::new(5, 3)),
536 "a sign flip without a transpose must not touch dimensions"
537 );
538 }
539
540 #[test]
541 fn grid_solution_rejected_for_present_and_absent() {
542 let rejected =
543 RejectedFeature::new(5, None, Some(3.0_f32), RejectionReason::ResidualTooHigh);
544 let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
545 let solution = GridSolution::new(grid, None, vec![rejected]);
546 assert!(solution.rejected_for(5).is_some());
547 assert!(solution.rejected_for(0).is_none());
548 }
549}