1use crate::board::{charuco_corner_id, CharucoBoard};
4use serde::{Deserialize, Serialize};
5use std::collections::{HashMap, HashSet};
6
7#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum LinkCheckMode {
11 #[default]
13 SubsetOk,
14 MustMatchAll4,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
20pub struct MarkerCornerLink {
21 pub marker_id: u32,
22 pub corner_id: u32,
23}
24
25#[derive(Clone, Debug, Default, Serialize, Deserialize)]
27pub struct CharucoMarkerCornerLinks {
28 pub links: Vec<MarkerCornerLink>,
29 #[serde(default)]
30 pub mode: LinkCheckMode,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum LinkViolationKind {
37 UnknownMarker,
38 MarkerHasNoFourCorners,
39 CornerNotInNeighborhood,
40 DuplicateCorner,
41 MissingCorners { missing: Vec<u32> },
42}
43
44#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
46pub struct LinkViolation {
47 pub marker_id: u32,
48 pub reported_corner_id: Option<u32>,
49 pub expected: Option<[u32; 4]>,
50 pub kind: LinkViolationKind,
51}
52
53#[derive(Clone, Copy, Debug)]
54enum MarkerExpectation {
55 Unknown,
56 NoFourCorners,
57 Expected { corners: [u32; 4] },
58}
59
60pub fn validate_marker_corner_links(
62 board: &CharucoBoard,
63 det: &CharucoMarkerCornerLinks,
64) -> Result<(), Vec<LinkViolation>> {
65 let mut violations = Vec::new();
66 let mut by_marker: HashMap<u32, Vec<u32>> = HashMap::new();
67 let mut expected_cache: HashMap<u32, MarkerExpectation> = HashMap::new();
68
69 for link in &det.links {
70 by_marker
71 .entry(link.marker_id)
72 .or_default()
73 .push(link.corner_id);
74 }
75
76 let squares_x = board.spec().cols as usize;
77 let squares_y = board.spec().rows as usize;
78
79 for link in &det.links {
80 let entry = expected_cache.entry(link.marker_id).or_insert_with(|| {
81 let Some((sx, sy)) = board.marker_cell(link.marker_id as i32) else {
82 return MarkerExpectation::Unknown;
83 };
84 if !is_internal_cell(squares_x, squares_y, sx, sy) {
85 return MarkerExpectation::NoFourCorners;
86 }
87 let expected = expected_corners_for_cell(squares_x, squares_y, sx, sy)
88 .expect("cell precondition ensures expected corners exist");
89 MarkerExpectation::Expected {
90 corners: expected.map(|v| v as u32),
91 }
92 });
93
94 match entry {
95 MarkerExpectation::Unknown => {
96 violations.push(LinkViolation {
97 marker_id: link.marker_id,
98 reported_corner_id: Some(link.corner_id),
99 expected: None,
100 kind: LinkViolationKind::UnknownMarker,
101 });
102 }
103 MarkerExpectation::NoFourCorners => {
104 violations.push(LinkViolation {
105 marker_id: link.marker_id,
106 reported_corner_id: Some(link.corner_id),
107 expected: None,
108 kind: LinkViolationKind::MarkerHasNoFourCorners,
109 });
110 }
111 MarkerExpectation::Expected { corners } => {
112 let expected_set: HashSet<u32> = corners.iter().copied().collect();
113 if !expected_set.contains(&link.corner_id) {
114 violations.push(LinkViolation {
115 marker_id: link.marker_id,
116 reported_corner_id: Some(link.corner_id),
117 expected: Some(*corners),
118 kind: LinkViolationKind::CornerNotInNeighborhood,
119 });
120 }
121 }
122 }
123 }
124
125 for (marker_id, reported) in by_marker {
126 let MarkerExpectation::Expected { corners } = expected_cache
127 .get(&marker_id)
128 .copied()
129 .unwrap_or(MarkerExpectation::Unknown)
130 else {
131 continue;
132 };
133
134 let expected_set: HashSet<u32> = corners.iter().copied().collect();
135 let mut seen = HashSet::new();
136 for &corner_id in &reported {
137 if !seen.insert(corner_id) {
138 violations.push(LinkViolation {
139 marker_id,
140 reported_corner_id: Some(corner_id),
141 expected: Some(corners),
142 kind: LinkViolationKind::DuplicateCorner,
143 });
144 }
145 }
146
147 if det.mode == LinkCheckMode::MustMatchAll4 {
148 let missing: Vec<u32> = expected_set.difference(&seen).copied().collect();
149 if !missing.is_empty() {
150 violations.push(LinkViolation {
151 marker_id,
152 reported_corner_id: None,
153 expected: Some(corners),
154 kind: LinkViolationKind::MissingCorners { missing },
155 });
156 }
157 }
158 }
159
160 if violations.is_empty() {
161 Ok(())
162 } else {
163 Err(violations)
164 }
165}
166
167fn is_internal_cell(squares_x: usize, squares_y: usize, sx: usize, sy: usize) -> bool {
168 squares_x >= 2
169 && squares_y >= 2
170 && sx >= 1
171 && sy >= 1
172 && sx + 1 < squares_x
173 && sy + 1 < squares_y
174}
175
176fn expected_corners_for_cell(
177 squares_x: usize,
178 squares_y: usize,
179 sx: usize,
180 sy: usize,
181) -> Option<[usize; 4]> {
182 let tl = charuco_corner_id(squares_x, squares_y, sx, sy)?;
183 let tr = charuco_corner_id(squares_x, squares_y, sx + 1, sy)?;
184 let br = charuco_corner_id(squares_x, squares_y, sx + 1, sy + 1)?;
185 let bl = charuco_corner_id(squares_x, squares_y, sx, sy + 1)?;
186 Some([tl, tr, br, bl])
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::board::{CharucoBoard, CharucoBoardSpec, MarkerLayout};
193 use calib_targets_aruco::builtins;
194
195 fn build_board() -> CharucoBoard {
196 let dict = builtins::builtin_dictionary("DICT_4X4_50").expect("dict");
197 CharucoBoard::new(CharucoBoardSpec {
198 rows: 5,
199 cols: 6,
200 cell_size: 1.0,
201 marker_size_rel: 0.75,
202 dictionary: dict,
203 marker_layout: MarkerLayout::OpenCvCharuco,
204 })
205 .expect("board")
206 }
207
208 #[test]
209 fn validate_links_ok() {
210 let board = build_board();
211 let marker_id = 4u32;
212 let expected = board
213 .marker_surrounding_charuco_corners(marker_id as i32)
214 .expect("expected corners");
215 let links = CharucoMarkerCornerLinks {
216 links: expected
217 .iter()
218 .map(|&corner_id| MarkerCornerLink {
219 marker_id,
220 corner_id: corner_id as u32,
221 })
222 .collect(),
223 mode: LinkCheckMode::MustMatchAll4,
224 };
225
226 assert!(validate_marker_corner_links(&board, &links).is_ok());
227 }
228
229 #[test]
230 fn validate_links_fails_on_wrong_corner() {
231 let board = build_board();
232 let marker_id = 4u32;
233 let expected = board
234 .marker_surrounding_charuco_corners(marker_id as i32)
235 .expect("expected corners");
236
237 let mut links: Vec<MarkerCornerLink> = expected
238 .iter()
239 .map(|&corner_id| MarkerCornerLink {
240 marker_id,
241 corner_id: corner_id as u32,
242 })
243 .collect();
244 links[0].corner_id = 0;
245
246 let det = CharucoMarkerCornerLinks {
247 links,
248 mode: LinkCheckMode::MustMatchAll4,
249 };
250
251 let err = validate_marker_corner_links(&board, &det).expect_err("should fail");
252 assert!(err.iter().any(|v| {
253 matches!(v.kind, LinkViolationKind::CornerNotInNeighborhood)
254 && v.expected == Some(expected.map(|v| v as u32))
255 }));
256 }
257}