calib_targets_chessboard/
detector.rs1use crate::gridgraph::{
2 assign_grid_coordinates, connected_components, GridGraph, NeighborDirection,
3};
4use crate::params::{ChessboardParams, GridGraphParams};
5use calib_targets_core::{
6 cluster_orientations, estimate_grid_axes_from_orientations, Corner, GridCoords, LabeledCorner,
7 OrientationHistogram, TargetDetection, TargetKind,
8};
9use log::{debug, warn};
10use serde::Serialize;
11use std::f32::consts::FRAC_PI_2;
12
13#[cfg(feature = "tracing")]
14use tracing::instrument;
15
16#[derive(Debug)]
18pub struct ChessboardDetector {
19 pub params: ChessboardParams,
20 pub grid_search: GridGraphParams,
21}
22
23#[derive(Debug, Serialize)]
24pub struct ChessboardDetectionResult {
25 pub detection: TargetDetection,
26 pub inliers: Vec<usize>,
27 pub orientations: Option<[f32; 2]>,
28 pub debug: ChessboardDebug,
29}
30
31#[derive(Clone, Debug, Serialize)]
32pub struct ChessboardDebug {
33 pub orientation_histogram: Option<OrientationHistogram>,
34 pub graph: Option<GridGraphDebug>,
35}
36
37#[derive(Clone, Debug, Serialize)]
38pub struct GridGraphDebug {
39 pub nodes: Vec<GridGraphNodeDebug>,
40}
41
42#[derive(Clone, Debug, Serialize)]
43pub struct GridGraphNodeDebug {
44 pub position: [f32; 2],
45 pub neighbors: Vec<GridGraphNeighborDebug>,
46}
47
48#[derive(Clone, Debug, Serialize)]
49pub struct GridGraphNeighborDebug {
50 pub index: usize,
51 pub direction: &'static str,
52 pub distance: f32,
53}
54
55impl ChessboardDetector {
56 pub fn new(params: ChessboardParams) -> Self {
57 Self {
58 grid_search: GridGraphParams::default(),
59 params,
60 }
61 }
62
63 pub fn with_grid_search(mut self, grid_search: GridGraphParams) -> Self {
64 self.grid_search = grid_search;
65 self
66 }
67
68 #[cfg_attr(feature = "tracing", instrument(level = "info", skip(self, corners), fields(num_corners=corners.len())))]
73 pub fn detect_from_corners(&self, corners: &[Corner]) -> Option<ChessboardDetectionResult> {
74 let mut strong: Vec<Corner> = corners
76 .iter()
77 .filter(|c| c.strength >= self.params.min_corner_strength)
78 .cloned()
79 .collect();
80
81 debug!(
82 "found {} raw ChESS corners after strength filter",
83 strong.len()
84 );
85
86 if strong.len() < self.params.min_corners {
87 return None;
88 }
89
90 let mut grid_diagonals = None;
92 let mut graph_diagonals = None;
93 let mut orientation_histogram = None;
94
95 if self.params.use_orientation_clustering {
96 if let Some(clusters) =
97 cluster_orientations(&strong, &self.params.orientation_clustering_params)
98 {
99 orientation_histogram = clusters.histogram;
100 grid_diagonals = Some(clusters.centers);
101 graph_diagonals = grid_diagonals;
102 strong = strong
103 .into_iter()
104 .zip(clusters.labels)
105 .filter_map(|(mut corner, label)| {
106 label.map(|cluster| {
107 corner.orientation_cluster = Some(cluster);
108 corner
109 })
110 })
111 .collect();
112 }
113 }
114
115 if grid_diagonals.is_none() {
116 warn!("Orientation clustering failed. Fallback to a simple estimate");
117 if let Some(theta) = estimate_grid_axes_from_orientations(&strong) {
118 let c0 = wrap_angle_pi(theta);
119 let c1 = wrap_angle_pi(theta + FRAC_PI_2);
120 grid_diagonals = Some([c0, c1]);
121 }
122 }
123
124 debug!(
125 "kept {} ChESS corners after orientation consistency filter",
126 strong.len()
127 );
128
129 if strong.len() < self.params.min_corners {
130 return None;
131 }
132
133 let graph = GridGraph::new(&strong, self.grid_search.clone(), graph_diagonals);
134
135 let components = connected_components(&graph);
136 debug!(
137 "found {} connected grid components after orientation filtering",
138 components.len()
139 );
140
141 let mut best: Option<(TargetDetection, Vec<usize>, usize)> = None;
142
143 for component in &components {
144 if component.len() < self.params.min_corners {
145 continue;
146 }
147 let coords = assign_grid_coordinates(&graph, component);
148 if coords.is_empty() {
149 continue;
150 }
151 let Some((detection, inliers)) = self.component_to_board_coords(&coords, &strong)
152 else {
153 continue;
154 };
155 let score = detection.corners.len();
156 match best {
157 None => best = Some((detection, inliers, score)),
158 Some((_, _, best_score)) if score > best_score => {
159 best = Some((detection, inliers, score));
160 }
161 _ => {}
162 }
163 }
164
165 let (detection, inliers, _) = best?;
166 let graph_debug = Some(build_graph_debug(&graph, &strong));
167
168 Some(ChessboardDetectionResult {
169 detection,
170 inliers,
171 orientations: grid_diagonals,
172 debug: ChessboardDebug {
173 orientation_histogram,
174 graph: graph_debug,
175 },
176 })
177 }
178
179 fn component_to_board_coords(
180 &self,
181 coords: &[(usize, i32, i32)],
182 corners: &[Corner],
183 ) -> Option<(TargetDetection, Vec<usize>)> {
184 let (min_i, max_i, min_j, max_j) = coords.iter().fold(
185 (i32::MAX, i32::MIN, i32::MAX, i32::MIN),
186 |acc, &(_, i, j)| (acc.0.min(i), acc.1.max(i), acc.2.min(j), acc.3.max(j)),
187 );
188
189 if min_i == i32::MAX || min_j == i32::MAX {
190 return None;
191 }
192
193 let width = (max_i - min_i + 1) as u32;
194 let height = (max_j - min_j + 1) as u32;
195
196 let (board_cols, board_rows, swap_axes) = select_board_size(width, height, &self.params)?;
197
198 let grid_area = (board_cols * board_rows) as f32;
199 if grid_area <= f32::EPSILON {
200 return None;
201 }
202
203 let mut by_grid: std::collections::HashMap<GridCoords, LabeledCorner> =
206 std::collections::HashMap::new();
207 for &(node_idx, i, j) in coords {
208 let corner = &corners[node_idx];
209 let (gi, gj) = if swap_axes {
210 (j - min_j, i - min_i)
211 } else {
212 (i - min_i, j - min_j)
213 };
214 let grid = GridCoords { i: gi, j: gj };
215 let candidate = LabeledCorner {
216 position: corner.position,
217 grid: Some(grid),
218 id: None,
219 target_position: None,
220 score: corner.strength,
221 };
222
223 match by_grid.get(&grid) {
224 None => {
225 by_grid.insert(grid, candidate);
226 }
227 Some(prev) => {
228 if candidate.score > prev.score {
229 by_grid.insert(grid, candidate);
230 }
231 }
232 }
233 }
234
235 let completeness = by_grid.len() as f32 / grid_area;
236 if let (Some(_), Some(_)) = (self.params.expected_cols, self.params.expected_rows) {
237 if completeness < self.params.completeness_threshold {
238 return None;
239 }
240 }
241
242 let mut labeled: Vec<LabeledCorner> = by_grid.into_values().collect();
243
244 labeled.sort_by(|a, b| {
245 let ga = a.grid.as_ref().unwrap();
246 let gb = b.grid.as_ref().unwrap();
247 (ga.j, ga.i).cmp(&(gb.j, gb.i))
248 });
249
250 let detection = TargetDetection {
251 kind: TargetKind::Chessboard,
252 corners: labeled,
253 };
254
255 let inliers = (0..detection.corners.len()).collect();
256
257 Some((detection, inliers))
258 }
259}
260
261fn select_board_size(
262 width: u32,
263 height: u32,
264 params: &ChessboardParams,
265) -> Option<(u32, u32, bool)> {
266 match (params.expected_cols, params.expected_rows) {
267 (Some(expected_cols), Some(expected_rows)) => {
268 let fits_direct = width <= expected_cols && height <= expected_rows;
269 let fits_swapped = width <= expected_rows && height <= expected_cols;
270
271 if !fits_direct && !fits_swapped {
272 return None;
273 }
274
275 let swap_axes = if fits_direct && !fits_swapped {
276 false
277 } else if !fits_direct && fits_swapped {
278 true
279 } else {
280 let gap_direct = (expected_cols - width) + (expected_rows - height);
281 let gap_swapped = (expected_rows - width) + (expected_cols - height);
282 gap_swapped < gap_direct
283 };
284
285 Some((expected_cols, expected_rows, swap_axes))
286 }
287 _ => Some((width, height, false)),
288 }
289}
290
291fn build_graph_debug(graph: &GridGraph, corners: &[Corner]) -> GridGraphDebug {
292 let nodes = graph
293 .neighbors
294 .iter()
295 .enumerate()
296 .map(|(idx, neighs)| {
297 let neighbors = neighs
298 .iter()
299 .map(|n| GridGraphNeighborDebug {
300 index: n.index,
301 direction: neighbor_dir_name(n.direction),
302 distance: n.distance,
303 })
304 .collect();
305 GridGraphNodeDebug {
306 position: [corners[idx].position.x, corners[idx].position.y],
307 neighbors,
308 }
309 })
310 .collect();
311
312 GridGraphDebug { nodes }
313}
314
315fn neighbor_dir_name(dir: NeighborDirection) -> &'static str {
316 match dir {
317 NeighborDirection::Right => "right",
318 NeighborDirection::Left => "left",
319 NeighborDirection::Up => "up",
320 NeighborDirection::Down => "down",
321 }
322}
323
324fn wrap_angle_pi(theta: f32) -> f32 {
325 let mut t = theta % std::f32::consts::PI;
326 if t < 0.0 {
327 t += std::f32::consts::PI;
328 }
329 t
330}