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, RadonBuffers};
30
31#[cfg(feature = "ml-refiner")]
32use crate::ml_refiner;
33use crate::multiscale;
34use crate::upscale::{self, UpscaleBuffers};
35use crate::{low_level::ImageView, ChessError, CornerDescriptor, 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 /// Borrow a detector-bound diagnostics accessor.
194 ///
195 /// The returned [`DetectorDiagnostics`](crate::diagnostics::DetectorDiagnostics)
196 /// exposes intermediate
197 /// detector outputs — the dense ChESS response map and the Radon
198 /// heatmap — sourced from this detector's already-configured
199 /// [`DetectorConfig`], so a caller holding a configured `Detector`
200 /// need not re-supply a config to obtain diagnostic data.
201 ///
202 /// This is the detector-bound half of the diagnostics channel; the
203 /// free functions in [`crate::diagnostics`] serve stateless
204 /// callers. Both share the same **opt-in, looser-stability**
205 /// contract: diagnostic outputs are advisory and may change as the
206 /// detector internals evolve, independently of the
207 /// [`Detector::detect`] result contract.
208 pub fn diagnostics(&self) -> crate::diagnostics::DetectorDiagnostics<'_> {
209 crate::diagnostics::DetectorDiagnostics::new(self)
210 }
211
212 #[allow(clippy::too_many_arguments)]
213 fn detect_view_inner(
214 cfg: &DetectorConfig,
215 pyramid: &mut PyramidBuffers,
216 chess_buffers: &mut ChessBuffers,
217 radon_buffers: &mut RadonBuffers,
218 #[cfg(feature = "ml-refiner")] ml_state: &mut Option<ml_refiner::MlRefinerState>,
219 #[cfg(feature = "ml-refiner")] ml_params: &ml_refiner::MlRefinerParams,
220 view: ImageView<'_>,
221 ) -> Vec<CornerDescriptor> {
222 #[cfg(feature = "ml-refiner")]
223 if Self::is_ml_refiner(cfg) {
224 if ml_state.is_none() {
225 let fallback = chess_corners_core::RefinerKind::CenterOfMass(
226 chess_corners_core::CenterOfMassConfig::default(),
227 );
228 *ml_state = Some(ml_refiner::MlRefinerState::new(ml_params, &fallback));
229 }
230 let state = ml_state.as_mut().expect("ml_state initialised above");
231 return multiscale::detect_with_ml(
232 view,
233 cfg,
234 pyramid,
235 chess_buffers,
236 radon_buffers,
237 ml_params,
238 state,
239 );
240 }
241
242 multiscale::detect_with_buffers(view, cfg, pyramid, chess_buffers, radon_buffers)
243 }
244
245 /// Whether the active config selects the ML refiner. Only true on
246 /// the ChESS path, since the Radon strategy carries a separate
247 /// refiner enum that has no ML variant.
248 #[cfg(feature = "ml-refiner")]
249 #[inline]
250 fn is_ml_refiner(cfg: &DetectorConfig) -> bool {
251 matches!(
252 &cfg.strategy,
253 crate::DetectionStrategy::Chess(c) if matches!(c.refiner, crate::ChessRefiner::Ml)
254 )
255 }
256}