calib_targets_charuco/
board.rs

1//! Board specification and layout helpers for ChArUco.
2
3use calib_targets_aruco::{BoardCell, Dictionary};
4use nalgebra::Point2;
5use serde::{Deserialize, Serialize};
6
7/// Marker placement scheme for the board.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(rename_all = "snake_case")]
10pub enum MarkerLayout {
11    /// OpenCV-style ChArUco layout:
12    /// - markers are placed on white squares only (assuming top-left square is black),
13    /// - marker IDs are assigned sequentially in row-major order over those squares.
14    #[serde(rename = "opencv_charuco", alias = "open_cv_charuco")]
15    #[default]
16    OpenCvCharuco,
17}
18
19/// Static ChArUco board specification.
20///
21/// `rows`/`cols` are **square counts** (not inner corner counts).
22#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
23pub struct CharucoBoardSpec {
24    pub rows: u32,
25    pub cols: u32,
26    pub cell_size: f32,
27    pub marker_size_rel: f32,
28    pub dictionary: Dictionary,
29    #[serde(default)]
30    pub marker_layout: MarkerLayout,
31}
32
33/// Board specification validation errors.
34#[derive(thiserror::Error, Debug)]
35pub enum CharucoBoardError {
36    #[error("rows and cols must be >= 2")]
37    InvalidSize,
38    #[error("cell_size must be > 0")]
39    InvalidCellSize,
40    #[error("marker_size_rel must be in (0, 1]")]
41    InvalidMarkerSizeRel,
42    #[error("dictionary has no codes")]
43    EmptyDictionary,
44    #[error("board needs {needed} markers, dictionary has {available}")]
45    NotEnoughDictionaryCodes { needed: usize, available: usize },
46}
47
48/// Precomputed board mapping helpers.
49#[derive(Clone, Debug)]
50pub struct CharucoBoard {
51    spec: CharucoBoardSpec,
52    marker_positions: Vec<BoardCell>,
53}
54
55impl CharucoBoard {
56    /// Validate and create a board from a spec.
57    pub fn new(spec: CharucoBoardSpec) -> Result<Self, CharucoBoardError> {
58        if spec.rows < 2 || spec.cols < 2 {
59            return Err(CharucoBoardError::InvalidSize);
60        }
61        if !spec.cell_size.is_finite() || spec.cell_size <= 0.0 {
62            return Err(CharucoBoardError::InvalidCellSize);
63        }
64        if !spec.marker_size_rel.is_finite()
65            || spec.marker_size_rel <= 0.0
66            || spec.marker_size_rel > 1.0
67        {
68            return Err(CharucoBoardError::InvalidMarkerSizeRel);
69        }
70        if spec.dictionary.codes.is_empty() {
71            return Err(CharucoBoardError::EmptyDictionary);
72        }
73
74        let marker_positions = match spec.marker_layout {
75            MarkerLayout::OpenCvCharuco => open_cv_charuco_marker_positions(spec.rows, spec.cols),
76        };
77
78        let needed = marker_positions.len();
79        let available = spec.dictionary.codes.len();
80        if available < needed {
81            return Err(CharucoBoardError::NotEnoughDictionaryCodes { needed, available });
82        }
83
84        Ok(Self {
85            spec,
86            marker_positions,
87        })
88    }
89
90    /// Return the underlying board specification.
91    #[inline]
92    pub fn spec(&self) -> CharucoBoardSpec {
93        self.spec
94    }
95
96    /// Expected number of *inner* chessboard corners in vertical direction.
97    #[inline]
98    pub fn expected_inner_rows(&self) -> u32 {
99        self.spec.rows - 1
100    }
101
102    /// Expected number of *inner* chessboard corners in horizontal direction.
103    #[inline]
104    pub fn expected_inner_cols(&self) -> u32 {
105        self.spec.cols - 1
106    }
107
108    /// Mapping from marker id -> board cell (square) coordinates.
109    #[inline]
110    pub fn marker_position(&self, id: u32) -> Option<BoardCell> {
111        self.marker_positions.get(id as usize).cloned()
112    }
113
114    /// Square-cell coordinates `(sx, sy)` for the given marker id.
115    ///
116    /// These are chessboard square indices in the board coordinate system.
117    pub fn marker_cell(&self, marker_id: i32) -> Option<(usize, usize)> {
118        let id = u32::try_from(marker_id).ok()?;
119        let bc = self.marker_position(id)?;
120        let sx = usize::try_from(bc.sx).ok()?;
121        let sy = usize::try_from(bc.sy).ok()?;
122        Some((sx, sy))
123    }
124
125    /// Return the four surrounding ChArUco corner ids for a marker (TL, TR, BR, BL).
126    ///
127    /// Returns `None` if the marker is unknown or lies on the board border
128    /// (i.e. not surrounded by 4 internal intersections).
129    pub fn marker_surrounding_charuco_corners(&self, marker_id: i32) -> Option<[usize; 4]> {
130        let (sx, sy) = self.marker_cell(marker_id)?;
131        marker_surrounding_charuco_corners_for_cell(
132            self.spec.cols as usize,
133            self.spec.rows as usize,
134            sx,
135            sy,
136        )
137    }
138
139    /// Number of markers on the board.
140    #[inline]
141    pub fn marker_count(&self) -> usize {
142        self.marker_positions.len()
143    }
144
145    /// Convert a board **corner coordinate** `(i, j)` into a ChArUco corner id.
146    ///
147    /// Returns `None` if the corner is outside the inner corner range.
148    pub fn charuco_corner_id_from_board_corner(&self, i: i32, j: i32) -> Option<u32> {
149        let cols = i32::try_from(self.spec.cols).ok()?;
150        let rows = i32::try_from(self.spec.rows).ok()?;
151
152        if i <= 0 || j <= 0 || i >= cols || j >= rows {
153            return None;
154        }
155
156        let inner_cols = cols - 1;
157        let ii = i - 1;
158        let jj = j - 1;
159        Some((jj as u32) * (inner_cols as u32) + (ii as u32))
160    }
161
162    /// Physical 2D point (board plane) for a ChArUco corner id.
163    ///
164    /// Coordinates are in the board reference frame with origin at the top-left board corner.
165    pub fn charuco_object_xy(&self, id: u32) -> Option<Point2<f32>> {
166        let cols = self.spec.cols.checked_sub(1)?; // inner corner cols
167        let rows = self.spec.rows.checked_sub(1)?; // inner corner rows
168        let count = cols.checked_mul(rows)?;
169        if id >= count {
170            return None;
171        }
172        let i = (id % cols) as f32 + 1.0;
173        let j = (id / cols) as f32 + 1.0;
174        Some(Point2::new(
175            i * self.spec.cell_size,
176            j * self.spec.cell_size,
177        ))
178    }
179}
180
181/// True if `(ix, iy)` is an internal intersection for a board with `squares_x` × `squares_y`.
182pub fn is_internal_intersection(squares_x: usize, squares_y: usize, ix: usize, iy: usize) -> bool {
183    squares_x >= 2
184        && squares_y >= 2
185        && (1..=squares_x - 1).contains(&ix)
186        && (1..=squares_y - 1).contains(&iy)
187}
188
189/// Row-major ChArUco corner id for an internal intersection `(ix, iy)`.
190pub fn charuco_corner_id(
191    squares_x: usize,
192    squares_y: usize,
193    ix: usize,
194    iy: usize,
195) -> Option<usize> {
196    if !is_internal_intersection(squares_x, squares_y, ix, iy) {
197        return None;
198    }
199    let stride = squares_x.checked_sub(1)?;
200    let ix0 = ix.checked_sub(1)?;
201    let iy0 = iy.checked_sub(1)?;
202    Some(iy0 * stride + ix0)
203}
204
205fn marker_surrounding_charuco_corners_for_cell(
206    squares_x: usize,
207    squares_y: usize,
208    sx: usize,
209    sy: usize,
210) -> Option<[usize; 4]> {
211    if squares_x < 2 || squares_y < 2 {
212        return None;
213    }
214    if sx == 0 || sy == 0 {
215        return None;
216    }
217    if sx + 1 >= squares_x || sy + 1 >= squares_y {
218        return None;
219    }
220    let tl = charuco_corner_id(squares_x, squares_y, sx, sy)?;
221    let tr = charuco_corner_id(squares_x, squares_y, sx + 1, sy)?;
222    let br = charuco_corner_id(squares_x, squares_y, sx + 1, sy + 1)?;
223    let bl = charuco_corner_id(squares_x, squares_y, sx, sy + 1)?;
224    Some([tl, tr, br, bl])
225}
226
227fn open_cv_charuco_marker_positions(rows: u32, cols: u32) -> Vec<BoardCell> {
228    let mut out = Vec::new();
229    for j in 0..(rows as i32) {
230        for i in 0..(cols as i32) {
231            // OpenCV: top-left square is black => white squares have (i+j) odd.
232            if ((i + j) & 1) == 1 {
233                out.push(BoardCell { sx: i, sy: j });
234            }
235        }
236    }
237    out
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use calib_targets_aruco::builtins;
244
245    fn build_board() -> CharucoBoard {
246        let dict = builtins::builtin_dictionary("DICT_4X4_50").expect("dict");
247        CharucoBoard::new(CharucoBoardSpec {
248            rows: 5,
249            cols: 6,
250            cell_size: 1.0,
251            marker_size_rel: 0.75,
252            dictionary: dict,
253            marker_layout: MarkerLayout::OpenCvCharuco,
254        })
255        .expect("board")
256    }
257
258    fn build_board_1000() -> CharucoBoard {
259        let dict = builtins::builtin_dictionary("DICT_4X4_1000").expect("dict");
260        CharucoBoard::new(CharucoBoardSpec {
261            rows: 22,
262            cols: 22,
263            cell_size: 1.0,
264            marker_size_rel: 0.75,
265            dictionary: dict,
266            marker_layout: MarkerLayout::OpenCvCharuco,
267        })
268        .expect("board")
269    }
270
271    #[test]
272    fn marker_surrounding_charuco_corners_matches_expected() {
273        let board = build_board();
274        let marker_id = 4;
275        let cell = board.marker_cell(marker_id).expect("marker cell");
276        assert_eq!(cell, (2, 1));
277
278        let corners = board
279            .marker_surrounding_charuco_corners(marker_id)
280            .expect("corners");
281        assert_eq!(corners, [1, 2, 7, 6]);
282    }
283
284    #[test]
285    fn border_marker_has_no_four_corner_neighborhood() {
286        let board = build_board();
287        let marker_id = 0;
288        let cell = board.marker_cell(marker_id).expect("marker cell");
289        assert_eq!(cell, (1, 0));
290        assert!(board
291            .marker_surrounding_charuco_corners(marker_id)
292            .is_none());
293    }
294
295    #[test]
296    fn marker_22() {
297        let board = build_board_1000();
298        let marker_id = 22;
299        let cell = board.marker_cell(marker_id).expect("marker cell");
300        assert_eq!(cell, (1, 2));
301        let corners = board
302            .marker_surrounding_charuco_corners(marker_id)
303            .expect("corners");
304        assert_eq!(corners, [21, 22, 43, 42]);
305    }
306}