chess_corners_core/detect/mod.rs
1//! Feature-detection pipelines.
2//!
3//! Two independent detector families share a common output type:
4//!
5//! - [`chess`] — the ChESS (Chess-board Extraction by Subtraction and
6//! Summation) detector: 16-sample ring kernel, NMS, cluster filtering,
7//! pluggable subpixel refinement.
8//! - [`radon`] — the Duda-Frese (2018) localized Radon detector:
9//! integral-image (SAT) ray sums, peak detection, Gaussian peak fit.
10//!
11//! Both families produce the same [`Corner`] / [`CornerDescriptor`]
12//! values, then go through the orthogonal subpixel-refinement and
13//! orientation-fit stages defined in [`crate::refine`] and
14//! [`crate::orientation`].
15
16pub mod chess;
17pub mod dense;
18mod merge;
19mod neighbors;
20pub mod radon;
21
22pub use chess::detect::{
23 detect_corners_from_response, detect_corners_from_response_with_refiner,
24 detect_peaks_from_response_with_refine_radius, find_corners_u8, refine_corners_on_image,
25};
26pub use merge::merge_corners_simple;
27pub(crate) use neighbors::{count_positive_neighbors, is_local_max};
28
29/// A detected corner candidate (subpixel position with raw response strength).
30#[derive(Clone, Debug)]
31#[non_exhaustive]
32pub struct Corner {
33 /// Subpixel x coordinate in image pixels.
34 pub x: f32,
35 /// Subpixel y coordinate in image pixels.
36 pub y: f32,
37 /// Raw detector response at the integer peak (before refinement).
38 pub strength: f32,
39}
40
41impl Corner {
42 /// Construct a [`Corner`].
43 #[inline]
44 pub fn new(x: f32, y: f32, strength: f32) -> Self {
45 Self { x, y, strength }
46 }
47}
48
49/// Direction of one local grid axis with its 1σ angular uncertainty.
50#[derive(Clone, Copy, Debug)]
51#[non_exhaustive]
52pub struct AxisEstimate {
53 /// Axis direction, radians in `[0, 2π)`.
54 ///
55 /// See [`CornerDescriptor`] for the joint polarity convention.
56 pub angle: f32,
57 /// 1σ angular uncertainty (radians), from the fit's covariance.
58 pub sigma: f32,
59}
60
61impl AxisEstimate {
62 /// Construct an [`AxisEstimate`].
63 #[inline]
64 pub fn new(angle: f32, sigma: f32) -> Self {
65 Self { angle, sigma }
66 }
67}
68
69/// Describes a detected chessboard corner in full-resolution image coordinates.
70///
71/// # Axis polarity convention
72///
73/// Local chessboard corner intensity patterns have exact 180° symmetry,
74/// so assigning an absolute `[0, 2π)` direction to any single axis ray
75/// is not possible from ring-local data. Instead the two axes are
76/// reported jointly:
77///
78/// - `axes[0].angle` lies in `[0, π)` — the "line direction" of axis 1.
79/// - `axes[1].angle` lies in `(axes[0].angle, axes[0].angle + π) ⊂ [0, 2π)`.
80///
81/// Together they satisfy: rotating CCW (in the usual `atan2(dy, dx)`
82/// sense — note: in image pixel coordinates with y-axis pointing down,
83/// this is a clockwise visual rotation) from `axes[0].angle` toward
84/// `axes[1].angle` traverses a **dark** sector of the corner. The
85/// second half-turn (`axes[0].angle + π → axes[1].angle + π`) crosses
86/// the second dark sector; the two remaining sectors are bright.
87///
88/// Each axis direction is signed as a `f32` in `[0, 2π)`; the axes are
89/// **not** assumed orthogonal (holds up under projective warp).
90///
91/// All [`crate::OrientationMethod`] variants emit axes
92/// under this same convention, so consumers may compare `axes[0]`
93/// (e.g. for slot-parity matching between cardinal grid neighbours)
94/// across methods without method-aware translation.
95///
96/// # Orientation skipped
97///
98/// [`axes`](Self::axes) is `None` when the per-corner orientation fit was
99/// skipped — the dominant per-corner cost. Consumers that derive board
100/// geometry themselves can disable it (the orientation method is `None` in
101/// the detector configuration); position and response are still produced.
102#[derive(Clone, Copy, Debug)]
103#[non_exhaustive]
104pub struct CornerDescriptor {
105 /// Subpixel position in full-resolution image pixels.
106 pub x: f32,
107 /// Subpixel y position in full-resolution image pixels.
108 pub y: f32,
109
110 /// Raw, **unnormalized** detector response at the detected peak. For
111 /// the ChESS path this is `R = SR − DR − 16·MR`
112 /// (see [`chess_response_u8`](crate::chess_response_u8)). Units are 8-bit
113 /// pixel sums; data-dependent. Do not interpret it as a probability,
114 /// a contrast, or a normalized strength.
115 pub response: f32,
116
117 /// The two local grid axis directions with per-axis 1σ precision, or
118 /// `None` when the orientation fit was skipped (orientation disabled in
119 /// the detector configuration).
120 pub axes: Option<[AxisEstimate; 2]>,
121}
122
123impl CornerDescriptor {
124 /// Construct a [`CornerDescriptor`] with a fitted orientation.
125 #[inline]
126 pub fn new(x: f32, y: f32, response: f32, axes: [AxisEstimate; 2]) -> Self {
127 Self {
128 x,
129 y,
130 response,
131 axes: Some(axes),
132 }
133 }
134
135 /// Construct a [`CornerDescriptor`] with the orientation fit skipped, so
136 /// [`axes`](Self::axes) is `None`.
137 #[inline]
138 pub fn without_axes(x: f32, y: f32, response: f32) -> Self {
139 Self {
140 x,
141 y,
142 response,
143 axes: None,
144 }
145 }
146}