chess_corners/detector.rs
1//! High-level chessboard-corner detector with reusable scratch buffers.
2//!
3//! [`Detector`] is the primary entry point for the `chess-corners`
4//! crate. It owns the [`DetectorConfig`] and the scratch buffers
5//! (pyramid, upscale, …) required to run detection without
6//! re-allocating across frames. It dispatches to either the ChESS or
7//! the Radon strategy depending on the active [`DetectorConfig::strategy`].
8//!
9//! ```
10//! use chess_corners::{Detector, DetectorConfig};
11//!
12//! // 8×8 black/white checkerboard of 16-pixel squares (128×128).
13//! let mut img = vec![0u8; 128 * 128];
14//! for y in 0..128 {
15//! for x in 0..128 {
16//! if ((x / 16) + (y / 16)) % 2 == 0 {
17//! img[y * 128 + x] = 255;
18//! }
19//! }
20//! }
21//!
22//! let mut detector = Detector::new(DetectorConfig::chess_multiscale())?;
23//! let corners = detector.detect_u8(&img, 128, 128)?;
24//! assert!(!corners.is_empty());
25//! # Ok::<(), chess_corners::ChessError>(())
26//! ```
27
28use box_image_pyramid::PyramidBuffers;
29use chess_corners_core::{ChessBuffers, CornerDescriptor, ImageView, RadonBuffers};
30
31#[cfg(feature = "ml-refiner")]
32use crate::ml_refiner;
33use crate::multiscale;
34use crate::upscale::{self, UpscaleBuffers};
35use crate::{ChessError, DetectorConfig};
36
37/// High-level chessboard-corner detector.
38///
39/// Owns the pyramid and detector-specific scratch buffers so the
40/// caller can reuse them across successive frames.
41pub struct Detector {
42 cfg: DetectorConfig,
43 pyramid: PyramidBuffers,
44 chess_buffers: ChessBuffers,
45 radon_buffers: RadonBuffers,
46 upscale: UpscaleBuffers,
47 #[cfg(feature = "ml-refiner")]
48 ml_state: Option<ml_refiner::MlRefinerState>,
49 #[cfg(feature = "ml-refiner")]
50 ml_params: ml_refiner::MlRefinerParams,
51}
52
53impl Detector {
54 /// Build a detector with the given config.
55 ///
56 /// # Errors
57 ///
58 /// Returns [`ChessError::Upscale`] when the [`DetectorConfig::upscale`]
59 /// configuration is invalid.
60 pub fn new(cfg: DetectorConfig) -> Result<Self, ChessError> {
61 cfg.upscale.validate()?;
62 Ok(Self {
63 cfg,
64 pyramid: PyramidBuffers::default(),
65 chess_buffers: ChessBuffers::default(),
66 radon_buffers: RadonBuffers::default(),
67 upscale: UpscaleBuffers::new(),
68 #[cfg(feature = "ml-refiner")]
69 ml_state: None,
70 #[cfg(feature = "ml-refiner")]
71 ml_params: ml_refiner::MlRefinerParams::default(),
72 })
73 }
74
75 /// Build a detector with the default config.
76 pub fn with_default() -> Self {
77 // DetectorConfig::default() always has a valid upscale config
78 // (`Off`), so `new` cannot fail here.
79 Self::new(DetectorConfig::default()).expect("default DetectorConfig is always valid")
80 }
81
82 /// Borrow the active config.
83 pub fn config(&self) -> &DetectorConfig {
84 &self.cfg
85 }
86
87 /// Replace the active config.
88 ///
89 /// # Errors
90 ///
91 /// Returns [`ChessError::Upscale`] when the new config's upscale
92 /// section is invalid.
93 pub fn set_config(&mut self, cfg: DetectorConfig) -> Result<(), ChessError> {
94 cfg.upscale.validate()?;
95 self.cfg = cfg;
96 // Drop ML state on config change so the next `detect` call
97 // re-builds it against the (possibly new) fallback refiner.
98 #[cfg(feature = "ml-refiner")]
99 {
100 self.ml_state = None;
101 }
102 Ok(())
103 }
104
105 /// Mutable access to the active config for ad-hoc tweaks. The
106 /// caller is responsible for keeping the config valid; callers
107 /// that change [`DetectorConfig::upscale`] should use
108 /// [`Self::set_config`] instead so the upscale invariants are
109 /// re-validated.
110 pub fn config_mut(&mut self) -> &mut DetectorConfig {
111 // Drop ML state on raw mutation; the next detect call rebuilds
112 // it against whatever fallback refiner the new config implies.
113 #[cfg(feature = "ml-refiner")]
114 {
115 self.ml_state = None;
116 }
117 &mut self.cfg
118 }
119
120 /// Detect chessboard corners from a raw 8-bit grayscale buffer.
121 ///
122 /// # Errors
123 ///
124 /// Returns [`ChessError::DimensionMismatch`] if `img.len() !=
125 /// width * height`. Returns [`ChessError::Upscale`] if the upscale
126 /// configuration becomes invalid (this should not normally
127 /// happen — [`Detector::new`] / [`Detector::set_config`] validate
128 /// up-front).
129 pub fn detect_u8(
130 &mut self,
131 img: &[u8],
132 width: u32,
133 height: u32,
134 ) -> Result<Vec<CornerDescriptor>, ChessError> {
135 let src_w = width as usize;
136 let src_h = height as usize;
137 let expected = src_w * src_h;
138 if img.len() != expected {
139 return Err(ChessError::DimensionMismatch {
140 expected,
141 actual: img.len(),
142 });
143 }
144
145 let factor = self.cfg.upscale.effective_factor();
146 if factor <= 1 {
147 let view =
148 ImageView::from_u8_slice(src_w, src_h, img).expect("dimensions were checked above");
149 return Ok(Self::detect_view_inner(
150 &self.cfg,
151 &mut self.pyramid,
152 &mut self.chess_buffers,
153 &mut self.radon_buffers,
154 #[cfg(feature = "ml-refiner")]
155 &mut self.ml_state,
156 #[cfg(feature = "ml-refiner")]
157 &self.ml_params,
158 view,
159 ));
160 }
161
162 // Split-borrow: each field is borrowed independently so
163 // `upscaled` (which borrows `self.upscale`) and the
164 // detect_view_inner call (which borrows other fields) don't
165 // conflict.
166 let upscaled = upscale::upscale_bilinear_u8(img, src_w, src_h, factor, &mut self.upscale)?;
167 let mut corners = Self::detect_view_inner(
168 &self.cfg,
169 &mut self.pyramid,
170 &mut self.chess_buffers,
171 &mut self.radon_buffers,
172 #[cfg(feature = "ml-refiner")]
173 &mut self.ml_state,
174 #[cfg(feature = "ml-refiner")]
175 &self.ml_params,
176 upscaled,
177 );
178 upscale::rescale_descriptors_to_input(&mut corners, factor);
179 Ok(corners)
180 }
181
182 /// Detect chessboard corners from an [`image::GrayImage`].
183 ///
184 /// # Errors
185 ///
186 /// Returns [`ChessError::Upscale`] if the upscale configuration
187 /// becomes invalid.
188 #[cfg(feature = "image")]
189 pub fn detect(&mut self, img: &image::GrayImage) -> Result<Vec<CornerDescriptor>, ChessError> {
190 self.detect_u8(img.as_raw(), img.width(), img.height())
191 }
192
193 /// Detect chessboard corners from a borrowed [`ImageView`].
194 ///
195 /// Lower-level than [`Self::detect_u8`] / [`Self::detect`]:
196 /// upscaling is not applied here. Use this when you have a
197 /// pre-upscaled image or you don't want the upscale pipeline at
198 /// all.
199 pub fn detect_view(&mut self, view: ImageView<'_>) -> Vec<CornerDescriptor> {
200 Self::detect_view_inner(
201 &self.cfg,
202 &mut self.pyramid,
203 &mut self.chess_buffers,
204 &mut self.radon_buffers,
205 #[cfg(feature = "ml-refiner")]
206 &mut self.ml_state,
207 #[cfg(feature = "ml-refiner")]
208 &self.ml_params,
209 view,
210 )
211 }
212
213 /// Compute the dense Radon response map for `img` at working
214 /// resolution. Convenience wrapper for visualisation; the detector
215 /// itself uses the response internally and never returns it.
216 ///
217 /// # Errors
218 ///
219 /// Returns [`ChessError::DimensionMismatch`] if `img.len() !=
220 /// width * height`. Returns [`ChessError::Upscale`] if the upscale
221 /// configuration is invalid.
222 pub fn radon_heatmap_u8(
223 &mut self,
224 img: &[u8],
225 width: u32,
226 height: u32,
227 ) -> Result<chess_corners_core::ResponseMap, ChessError> {
228 crate::radon::radon_heatmap_u8(img, width, height, &self.cfg)
229 }
230
231 /// Compute the dense Radon response map from an
232 /// [`image::GrayImage`]. See [`Self::radon_heatmap_u8`].
233 ///
234 /// # Errors
235 ///
236 /// Inherits the error contract of [`Self::radon_heatmap_u8`].
237 #[cfg(feature = "image")]
238 pub fn radon_heatmap(
239 &mut self,
240 img: &image::GrayImage,
241 ) -> Result<chess_corners_core::ResponseMap, ChessError> {
242 self.radon_heatmap_u8(img.as_raw(), img.width(), img.height())
243 }
244
245 #[allow(clippy::too_many_arguments)]
246 fn detect_view_inner(
247 cfg: &DetectorConfig,
248 pyramid: &mut PyramidBuffers,
249 chess_buffers: &mut ChessBuffers,
250 radon_buffers: &mut RadonBuffers,
251 #[cfg(feature = "ml-refiner")] ml_state: &mut Option<ml_refiner::MlRefinerState>,
252 #[cfg(feature = "ml-refiner")] ml_params: &ml_refiner::MlRefinerParams,
253 view: ImageView<'_>,
254 ) -> Vec<CornerDescriptor> {
255 #[cfg(feature = "ml-refiner")]
256 if Self::is_ml_refiner(cfg) {
257 if ml_state.is_none() {
258 let fallback = chess_corners_core::RefinerKind::CenterOfMass(
259 chess_corners_core::CenterOfMassConfig::default(),
260 );
261 *ml_state = Some(ml_refiner::MlRefinerState::new(ml_params, &fallback));
262 }
263 let state = ml_state.as_mut().expect("ml_state initialised above");
264 return multiscale::detect_with_ml(
265 view,
266 cfg,
267 pyramid,
268 chess_buffers,
269 radon_buffers,
270 ml_params,
271 state,
272 );
273 }
274
275 multiscale::detect_with_buffers(view, cfg, pyramid, chess_buffers, radon_buffers)
276 }
277
278 /// Whether the active config selects the ML refiner. Only true on
279 /// the ChESS path, since the Radon strategy carries a separate
280 /// refiner enum that has no ML variant.
281 #[cfg(feature = "ml-refiner")]
282 #[inline]
283 fn is_ml_refiner(cfg: &DetectorConfig) -> bool {
284 matches!(
285 &cfg.strategy,
286 crate::DetectionStrategy::Chess(c) if matches!(c.refiner, crate::ChessRefiner::Ml)
287 )
288 }
289}