calib_targets_charuco/
validation.rs

1//! Marker-to-corner linkage validation for ChArUco detections.
2
3use crate::board::{charuco_corner_id, CharucoBoard};
4use serde::{Deserialize, Serialize};
5use std::collections::{HashMap, HashSet};
6
7/// How strictly to validate marker-to-corner links.
8#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum LinkCheckMode {
11    /// Reported corners must be a unique subset of the expected 4 corners.
12    #[default]
13    SubsetOk,
14    /// Reported corners must be exactly the expected 4 corners.
15    MustMatchAll4,
16}
17
18/// One reported marker-corner link.
19#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
20pub struct MarkerCornerLink {
21    pub marker_id: u32,
22    pub corner_id: u32,
23}
24
25/// Collection of reported marker-corner links plus validation mode.
26#[derive(Clone, Debug, Default, Serialize, Deserialize)]
27pub struct CharucoMarkerCornerLinks {
28    pub links: Vec<MarkerCornerLink>,
29    #[serde(default)]
30    pub mode: LinkCheckMode,
31}
32
33/// Specific violation encountered while validating marker-corner links.
34#[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/// One validation error with context.
45#[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
60/// Validate marker-corner links against the board definition.
61pub 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}