Skip to main content

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}