calib_targets_chessboard/
detector.rs

1use 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/// Simple chessboard detector using ChESS orientations + grid fitting in (u, v) space.
17#[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    /// Main entry point: find chessboard(s) in a cloud of ChESS corners.
69    ///
70    /// This function expects corners already computed by your ChESS crate.
71    /// For now it returns at most one detection (the best-scoring grid component).
72    #[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        // 1. Filter by strength.
75        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        // 2. Estimate grid axes from orientations.
91        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        // De-duplicate by grid coordinate: in noisy graphs, a component can contain
204        // multiple corners that get mapped to the same (i,j). Keep the strongest one.
205        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}