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 lattice: LatticeKind,
49 entries: Vec<GridEntry>,
51 bbox: Option<(Coord, Coord)>,
53 dimensions: Option<GridDimensions>,
55}
56
57impl LabelledGrid {
58 pub(crate) 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 lattice(&self) -> LatticeKind {
75 self.lattice
76 }
77
78 pub fn entries(&self) -> &[GridEntry] {
80 &self.entries
81 }
82
83 pub(crate) fn into_entries(self) -> Vec<GridEntry> {
84 self.entries
85 }
86
87 pub fn bbox(&self) -> Option<(Coord, Coord)> {
89 self.bbox
90 }
91
92 pub fn dimensions(&self) -> Option<GridDimensions> {
94 self.dimensions
95 }
96
97 pub(crate) fn normalized_square_entries(entries: Vec<GridEntry>) -> Vec<GridEntry> {
98 let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
99 grid.normalize();
100 grid.into_entries()
101 }
102
103 pub fn find(&self, source_index: usize) -> Option<&GridEntry> {
105 self.entries.iter().find(|e| e.source_index == source_index)
106 }
107
108 pub(crate) fn normalize(&mut self) {
134 rebase_entries_to_origin(&mut self.entries);
135 let swapped = canonicalize_to_image_axes(&mut self.entries);
136 if swapped {
137 if let Some(dims) = self.dimensions.as_mut() {
141 std::mem::swap(&mut dims.width, &mut dims.height);
142 }
143 }
144 self.entries.sort_by_key(|e| (e.coord.v, e.coord.u));
145 self.bbox = bbox_for_entries(&self.entries);
146 }
147}
148
149#[derive(Clone, Copy, Debug, PartialEq)]
151#[non_exhaustive]
152pub struct ResidualSummary {
153 pub count: usize,
155 pub mean_px: f32,
157 pub max_px: f32,
159}
160
161impl ResidualSummary {
162 pub fn new(count: usize, mean_px: f32, max_px: f32) -> Self {
164 Self {
165 count,
166 mean_px,
167 max_px,
168 }
169 }
170}
171
172#[derive(Clone, Debug, PartialEq)]
174#[non_exhaustive]
175pub struct LatticeFit {
176 pub model_to_image: Projective2<f32>,
178 pub residuals: ResidualSummary,
180}
181
182impl LatticeFit {
183 pub fn new(model_to_image: Projective2<f32>, residuals: ResidualSummary) -> Self {
185 Self {
186 model_to_image,
187 residuals,
188 }
189 }
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
194#[non_exhaustive]
195pub enum RejectionReason {
196 ResidualTooHigh,
198 Unlabelled,
201 ValidationDropped,
205}
206
207#[derive(Clone, Copy, Debug, PartialEq)]
209#[non_exhaustive]
210pub struct RejectedFeature {
211 pub source_index: usize,
213 pub coord: Option<Coord>,
215 pub residual_px: Option<f32>,
217 pub reason: RejectionReason,
219}
220
221impl RejectedFeature {
222 pub fn new(
224 source_index: usize,
225 coord: Option<Coord>,
226 residual_px: Option<f32>,
227 reason: RejectionReason,
228 ) -> Self {
229 Self {
230 source_index,
231 coord,
232 residual_px,
233 reason,
234 }
235 }
236}
237
238#[derive(Clone, Debug, PartialEq)]
243#[non_exhaustive]
244pub struct GridDetection {
245 grid: LabelledGrid,
246 fit: LatticeFit,
247}
248
249impl GridDetection {
250 pub(crate) fn new(grid: LabelledGrid, fit: LatticeFit) -> Self {
251 Self { grid, fit }
252 }
253
254 pub fn grid(&self) -> &LabelledGrid {
256 &self.grid
257 }
258
259 pub fn fit(&self) -> &LatticeFit {
261 &self.fit
262 }
263}
264
265#[derive(Clone, Debug, PartialEq)]
266pub(crate) struct GridSolution {
267 pub(crate) detection: GridDetection,
268 pub(crate) rejected: Vec<RejectedFeature>,
269}
270
271impl GridSolution {
272 pub(crate) fn new(grid: LabelledGrid, fit: LatticeFit, rejected: Vec<RejectedFeature>) -> Self {
273 Self {
274 detection: GridDetection::new(grid, fit),
275 rejected,
276 }
277 }
278}
279
280#[derive(Clone, Debug, PartialEq)]
282#[non_exhaustive]
283pub struct ConsistencyReport {
284 passed: bool,
286 grid: LabelledGrid,
287 fit: LatticeFit,
288 rejected: Vec<RejectedFeature>,
289}
290
291impl ConsistencyReport {
292 pub(crate) fn new(
294 passed: bool,
295 grid: LabelledGrid,
296 fit: LatticeFit,
297 rejected: Vec<RejectedFeature>,
298 ) -> Self {
299 Self {
300 passed,
301 grid,
302 fit,
303 rejected,
304 }
305 }
306
307 pub fn passed(&self) -> bool {
309 self.passed
310 }
311
312 pub fn grid(&self) -> &LabelledGrid {
314 &self.grid
315 }
316
317 pub fn fit(&self) -> &LatticeFit {
319 &self.fit
320 }
321
322 pub fn rejected(&self) -> &[RejectedFeature] {
324 &self.rejected
325 }
326
327 pub fn max_residual_px(&self) -> f32 {
330 self.fit.residuals.max_px
331 }
332}
333
334fn rebase_entries_to_origin(entries: &mut [GridEntry]) {
336 if entries.is_empty() {
337 return;
338 }
339 let (min_u, min_v) = entries.iter().fold((i32::MAX, i32::MAX), |(a, b), e| {
340 (a.min(e.coord.u), b.min(e.coord.v))
341 });
342 if min_u != 0 || min_v != 0 {
343 for e in entries.iter_mut() {
344 e.coord.u -= min_u;
345 e.coord.v -= min_v;
346 }
347 }
348}
349
350fn canonicalize_to_image_axes(entries: &mut [GridEntry]) -> bool {
364 if entries.len() < 2 {
365 return false;
366 }
367 let pos_by_uv: HashMap<(i32, i32), (f32, f32)> = entries
368 .iter()
369 .map(|e| {
370 (
371 (e.coord.u, e.coord.v),
372 (e.image_position.x, e.image_position.y),
373 )
374 })
375 .collect();
376
377 let mut keys: Vec<(i32, i32)> = pos_by_uv.keys().copied().collect();
378 keys.sort_unstable();
379 let mut vu_sum = (0.0_f32, 0.0_f32);
380 let mut vv_sum = (0.0_f32, 0.0_f32);
381 let mut vu_n = 0u32;
382 let mut vv_n = 0u32;
383 for &(u, v) in &keys {
384 let (x, y) = pos_by_uv[&(u, v)];
385 if let Some(&(xn, yn)) = pos_by_uv.get(&(u + 1, v)) {
386 vu_sum.0 += xn - x;
387 vu_sum.1 += yn - y;
388 vu_n += 1;
389 }
390 if let Some(&(xn, yn)) = pos_by_uv.get(&(u, v + 1)) {
391 vv_sum.0 += xn - x;
392 vv_sum.1 += yn - y;
393 vv_n += 1;
394 }
395 }
396 if vu_n == 0 || vv_n == 0 {
397 return false;
398 }
399 let vu = (vu_sum.0 / vu_n as f32, vu_sum.1 / vu_n as f32);
400 let vv = (vv_sum.0 / vv_n as f32, vv_sum.1 / vv_n as f32);
401
402 let swap = vu.0.abs() < vv.0.abs();
404 let new_vu = if swap { vv } else { vu };
405 let new_vv = if swap { vu } else { vv };
406 let flip_u = new_vu.0 < 0.0;
407 let flip_v = new_vv.1 < 0.0;
408
409 if !swap && !flip_u && !flip_v {
410 return false;
411 }
412
413 let mut umax = i32::MIN;
415 let mut vmax = i32::MIN;
416 for e in entries.iter() {
417 let (nu, nv) = if swap {
418 (e.coord.v, e.coord.u)
419 } else {
420 (e.coord.u, e.coord.v)
421 };
422 umax = umax.max(nu);
423 vmax = vmax.max(nv);
424 }
425
426 for e in entries.iter_mut() {
427 let (mut nu, mut nv) = if swap {
428 (e.coord.v, e.coord.u)
429 } else {
430 (e.coord.u, e.coord.v)
431 };
432 if flip_u {
433 nu = umax - nu;
434 }
435 if flip_v {
436 nv = vmax - nv;
437 }
438 e.coord.u = nu;
439 e.coord.v = nv;
440 }
441
442 swap
443}
444
445fn bbox_for_entries(entries: &[GridEntry]) -> Option<(Coord, Coord)> {
446 let first = entries.first()?;
447 let mut min = first.coord;
448 let mut max = first.coord;
449 for entry in &entries[1..] {
450 min.u = min.u.min(entry.coord.u);
451 min.v = min.v.min(entry.coord.v);
452 max.u = max.u.max(entry.coord.u);
453 max.v = max.v.max(entry.coord.v);
454 }
455 Some((min, max))
456}
457
458#[cfg(test)]
459mod tests {
460 use nalgebra::{Point2, Projective2};
461
462 use super::*;
463
464 fn make_identity_fit() -> LatticeFit {
465 LatticeFit::new(
466 Projective2::identity(),
467 ResidualSummary::new(1, 0.5_f32, 1.0_f32),
468 )
469 }
470
471 #[test]
472 fn consistency_report_exposes_mandatory_fit() {
473 let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
474 let fit = make_identity_fit();
475 let report = ConsistencyReport::new(true, grid, fit, vec![]);
476 assert_eq!(report.max_residual_px(), 1.0_f32);
477 }
478
479 #[test]
480 fn labelled_grid_find_present_and_absent() {
481 let entry = GridEntry::new(Coord::new(0, 0), 42, Point2::new(1.0_f32, 2.0), None);
482 let grid = LabelledGrid::new(LatticeKind::Square, vec![entry], None);
483 assert!(grid.find(42).is_some());
484 assert!(grid.find(99).is_none());
485 }
486
487 fn mk_entry(u: i32, v: i32, x: f32, y: f32) -> GridEntry {
488 GridEntry::new(Coord::new(u, v), 0, Point2::new(x, y), None)
489 }
490
491 fn coord_by_pos(grid: &LabelledGrid) -> HashMap<(i32, i32), (i32, i32)> {
492 grid.entries
493 .iter()
494 .map(|e| {
495 (
496 (e.image_position.x as i32, e.image_position.y as i32),
497 (e.coord.u, e.coord.v),
498 )
499 })
500 .collect()
501 }
502
503 #[test]
504 fn normalize_rebases_and_sorts_already_canonical() {
505 let entries = vec![
507 mk_entry(3, 5, 10.0, 10.0),
508 mk_entry(4, 5, 20.0, 10.0),
509 mk_entry(3, 6, 10.0, 20.0),
510 mk_entry(4, 6, 20.0, 20.0),
511 ];
512 let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
513 grid.normalize();
514 let by_pos = coord_by_pos(&grid);
515 assert_eq!(by_pos[&(10, 10)], (0, 0));
516 assert_eq!(by_pos[&(20, 10)], (1, 0));
517 assert_eq!(by_pos[&(10, 20)], (0, 1));
518 assert_eq!(by_pos[&(20, 20)], (1, 1));
519 assert_eq!(grid.bbox, Some((Coord::new(0, 0), Coord::new(1, 1))));
520 let order: Vec<(i32, i32)> = grid
522 .entries
523 .iter()
524 .map(|e| (e.coord.u, e.coord.v))
525 .collect();
526 assert_eq!(order, vec![(0, 0), (1, 0), (0, 1), (1, 1)]);
527 }
528
529 #[test]
530 fn normalize_canonicalizes_rotated_axes() {
531 let entries = vec![
535 mk_entry(0, 0, 10.0, 10.0),
536 mk_entry(0, 1, 20.0, 10.0),
537 mk_entry(1, 0, 10.0, 20.0),
538 mk_entry(1, 1, 20.0, 20.0),
539 ];
540 let mut grid = LabelledGrid::new(LatticeKind::Square, entries, None);
541 grid.normalize();
542 let by_pos = coord_by_pos(&grid);
543 assert_eq!(
544 by_pos[&(10, 10)],
545 (0, 0),
546 "(0,0) must land at smallest (x,y)"
547 );
548 assert_eq!(by_pos[&(20, 10)], (1, 0), "+u must point +x");
549 assert_eq!(by_pos[&(10, 20)], (0, 1), "+v must point +y");
550 }
551
552 #[test]
553 fn normalize_transposes_dimensions_on_axis_swap() {
554 let entries = vec![
559 mk_entry(0, 0, 10.0, 10.0),
560 mk_entry(0, 1, 20.0, 10.0),
561 mk_entry(1, 0, 10.0, 20.0),
562 mk_entry(1, 1, 20.0, 20.0),
563 ];
564 let mut grid = LabelledGrid::new(
565 LatticeKind::Square,
566 entries,
567 Some(GridDimensions::new(5, 3)),
568 );
569 grid.normalize();
570 assert_eq!(
571 grid.dimensions,
572 Some(GridDimensions::new(3, 5)),
573 "axis swap must transpose width/height"
574 );
575 }
576
577 #[test]
578 fn normalize_keeps_dimensions_when_axes_only_flip() {
579 let entries = vec![
582 mk_entry(0, 0, 20.0, 10.0),
583 mk_entry(1, 0, 10.0, 10.0),
584 mk_entry(0, 1, 20.0, 20.0),
585 mk_entry(1, 1, 10.0, 20.0),
586 ];
587 let mut grid = LabelledGrid::new(
588 LatticeKind::Square,
589 entries,
590 Some(GridDimensions::new(5, 3)),
591 );
592 grid.normalize();
593 assert_eq!(
594 grid.dimensions,
595 Some(GridDimensions::new(5, 3)),
596 "a sign flip without a transpose must not touch dimensions"
597 );
598 }
599
600 #[test]
601 fn consistency_report_exposes_rejections() {
602 let rejected =
603 RejectedFeature::new(5, None, Some(3.0_f32), RejectionReason::ResidualTooHigh);
604 let grid = LabelledGrid::new(LatticeKind::Square, vec![], None);
605 let report = ConsistencyReport::new(true, grid, make_identity_fit(), vec![rejected]);
606 assert_eq!(report.rejected()[0].source_index, 5);
607 }
608}