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, RadonBuffers};
30
31#[cfg(feature = "ml-refiner")]
32use crate::ml_refiner;
33use crate::multiscale;
34use crate::upscale::{self, UpscaleBuffers};
35use crate::{ChessError, CornerDescriptor, DetectorConfig};
36use chess_corners_core::ImageView;
37
38/// High-level chessboard-corner detector.
39///
40/// Owns the pyramid and detector-specific scratch buffers so the
41/// caller can reuse them across successive frames.
42pub struct Detector {
43    cfg: DetectorConfig,
44    pyramid: PyramidBuffers,
45    chess_buffers: ChessBuffers,
46    radon_buffers: RadonBuffers,
47    upscale: UpscaleBuffers,
48    #[cfg(feature = "ml-refiner")]
49    ml_state: Option<ml_refiner::MlRefinerState>,
50    #[cfg(feature = "ml-refiner")]
51    ml_params: ml_refiner::MlRefinerParams,
52}
53
54impl Detector {
55    /// Build a detector with the given config.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`ChessError::Upscale`] when the [`DetectorConfig::upscale`]
60    /// configuration is invalid.
61    pub fn new(cfg: DetectorConfig) -> Result<Self, ChessError> {
62        cfg.upscale.validate()?;
63        Ok(Self {
64            cfg,
65            pyramid: PyramidBuffers::default(),
66            chess_buffers: ChessBuffers::default(),
67            radon_buffers: RadonBuffers::default(),
68            upscale: UpscaleBuffers::new(),
69            #[cfg(feature = "ml-refiner")]
70            ml_state: None,
71            #[cfg(feature = "ml-refiner")]
72            ml_params: ml_refiner::MlRefinerParams::default(),
73        })
74    }
75
76    /// Build a detector with the default config.
77    pub fn with_default() -> Self {
78        // DetectorConfig::default() always has a valid upscale config
79        // (`Off`), so `new` cannot fail here.
80        Self::new(DetectorConfig::default()).expect("default DetectorConfig is always valid")
81    }
82
83    /// Borrow the active config.
84    pub fn config(&self) -> &DetectorConfig {
85        &self.cfg
86    }
87
88    /// Replace the active config.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`ChessError::Upscale`] when the new config's upscale
93    /// section is invalid.
94    pub fn set_config(&mut self, cfg: DetectorConfig) -> Result<(), ChessError> {
95        cfg.upscale.validate()?;
96        self.cfg = cfg;
97        // Drop ML state on config change so the next `detect` call
98        // re-builds it against the (possibly new) fallback refiner.
99        #[cfg(feature = "ml-refiner")]
100        {
101            self.ml_state = None;
102        }
103        Ok(())
104    }
105
106    /// Detect chessboard corners from a raw 8-bit grayscale buffer.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`ChessError::DimensionMismatch`] if `img.len() !=
111    /// width * height`. Returns [`ChessError::Upscale`] if the upscale
112    /// configuration becomes invalid (this should not normally
113    /// happen — [`Detector::new`] / [`Detector::set_config`] validate
114    /// up-front).
115    pub fn detect_u8(
116        &mut self,
117        img: &[u8],
118        width: u32,
119        height: u32,
120    ) -> Result<Vec<CornerDescriptor>, ChessError> {
121        let src_w = width as usize;
122        let src_h = height as usize;
123        let expected = src_w * src_h;
124        if img.len() != expected {
125            return Err(ChessError::DimensionMismatch {
126                expected,
127                actual: img.len(),
128            });
129        }
130
131        let factor = self.cfg.upscale.effective_factor();
132        if factor <= 1 {
133            let view =
134                ImageView::from_u8_slice(src_w, src_h, img).expect("dimensions were checked above");
135            return Ok(Self::detect_view_inner(
136                &self.cfg,
137                &mut self.pyramid,
138                &mut self.chess_buffers,
139                &mut self.radon_buffers,
140                #[cfg(feature = "ml-refiner")]
141                &mut self.ml_state,
142                #[cfg(feature = "ml-refiner")]
143                &self.ml_params,
144                view,
145            ));
146        }
147
148        // Split-borrow: each field is borrowed independently so
149        // `upscaled` (which borrows `self.upscale`) and the
150        // detect_view_inner call (which borrows other fields) don't
151        // conflict.
152        let upscaled = upscale::upscale_bilinear_u8(img, src_w, src_h, factor, &mut self.upscale)?;
153        let mut corners = Self::detect_view_inner(
154            &self.cfg,
155            &mut self.pyramid,
156            &mut self.chess_buffers,
157            &mut self.radon_buffers,
158            #[cfg(feature = "ml-refiner")]
159            &mut self.ml_state,
160            #[cfg(feature = "ml-refiner")]
161            &self.ml_params,
162            upscaled,
163        );
164        upscale::rescale_descriptors_to_input(&mut corners, factor);
165        Ok(corners)
166    }
167
168    /// Detect chessboard corners from an [`image::GrayImage`].
169    ///
170    /// # Errors
171    ///
172    /// Returns [`ChessError::Upscale`] if the upscale configuration
173    /// becomes invalid.
174    #[cfg(feature = "image")]
175    pub fn detect(&mut self, img: &image::GrayImage) -> Result<Vec<CornerDescriptor>, ChessError> {
176        self.detect_u8(img.as_raw(), img.width(), img.height())
177    }
178
179    /// Borrow a detector-bound diagnostics accessor.
180    ///
181    /// The returned [`DetectorDiagnostics`](crate::diagnostics::DetectorDiagnostics)
182    /// exposes intermediate
183    /// detector outputs — the dense ChESS response map and the Radon
184    /// heatmap — sourced from this detector's already-configured
185    /// [`DetectorConfig`], so a caller holding a configured `Detector`
186    /// need not re-supply a config to obtain diagnostic data.
187    ///
188    /// This is the detector-bound half of the diagnostics channel; the
189    /// free functions in [`crate::diagnostics`] serve stateless
190    /// callers. Both share the same **opt-in, looser-stability**
191    /// contract: diagnostic outputs are advisory and may change as the
192    /// detector internals evolve, independently of the
193    /// [`Detector::detect`] result contract.
194    pub fn diagnostics(&self) -> crate::diagnostics::DetectorDiagnostics<'_> {
195        crate::diagnostics::DetectorDiagnostics::new(self)
196    }
197
198    fn detect_view_inner(
199        cfg: &DetectorConfig,
200        pyramid: &mut PyramidBuffers,
201        chess_buffers: &mut ChessBuffers,
202        radon_buffers: &mut RadonBuffers,
203        #[cfg(feature = "ml-refiner")] ml_state: &mut Option<ml_refiner::MlRefinerState>,
204        #[cfg(feature = "ml-refiner")] ml_params: &ml_refiner::MlRefinerParams,
205        view: ImageView<'_>,
206    ) -> Vec<CornerDescriptor> {
207        #[cfg(feature = "ml-refiner")]
208        if Self::is_ml_refiner(cfg) {
209            if ml_state.is_none() {
210                let fallback = chess_corners_core::RefinerKind::CenterOfMass(
211                    chess_corners_core::CenterOfMassConfig::default(),
212                );
213                *ml_state = Some(ml_refiner::MlRefinerState::new(ml_params, &fallback));
214            }
215            let state = ml_state.as_mut().expect("ml_state initialised above");
216            return multiscale::detect_with_ml(
217                view,
218                cfg,
219                pyramid,
220                chess_buffers,
221                radon_buffers,
222                ml_params,
223                state,
224            );
225        }
226
227        multiscale::detect_with_buffers(view, cfg, pyramid, chess_buffers, radon_buffers)
228    }
229
230    /// Whether the active config selects the ML refiner. Only true on
231    /// the ChESS path, since the Radon strategy carries a separate
232    /// refiner enum that has no ML variant.
233    #[cfg(feature = "ml-refiner")]
234    #[inline]
235    fn is_ml_refiner(cfg: &DetectorConfig) -> bool {
236        matches!(
237            &cfg.strategy,
238            crate::DetectionStrategy::Chess(c) if matches!(c.refiner, crate::ChessRefiner::Ml)
239        )
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::UpscaleConfig;
247    use chess_corners_testutil::aa_chessboard;
248
249    fn synthetic_board(size: usize) -> Vec<u8> {
250        aa_chessboard(size, 12, (0.0, 0.0), 20, 220)
251    }
252
253    #[test]
254    fn detect_u8_reports_dimension_mismatch() {
255        let mut det = Detector::with_default();
256        let img = vec![0u8; 10];
257        let err = det.detect_u8(&img, 8, 8).unwrap_err();
258        match err {
259            ChessError::DimensionMismatch { expected, actual } => {
260                assert_eq!(expected, 64);
261                assert_eq!(actual, 10);
262            }
263            other => panic!("expected ChessError::DimensionMismatch, got {other:?}"),
264        }
265    }
266
267    #[test]
268    fn set_config_valid_swap_changes_detection_outcome() {
269        let size = 96usize;
270        let img = synthetic_board(size);
271
272        let mut det = Detector::new(DetectorConfig::chess().with_threshold(30.0)).unwrap();
273        let low_threshold = det.detect_u8(&img, size as u32, size as u32).unwrap();
274        assert!(
275            !low_threshold.is_empty(),
276            "expected corners at the default threshold"
277        );
278
279        det.set_config(DetectorConfig::chess().with_threshold(5000.0))
280            .expect("valid upscale config");
281        let high_threshold = det.detect_u8(&img, size as u32, size as u32).unwrap();
282        assert!(
283            high_threshold.len() < low_threshold.len(),
284            "raising the response threshold far above real corner strengths \
285             must suppress detections: low={} high={}",
286            low_threshold.len(),
287            high_threshold.len()
288        );
289    }
290
291    #[test]
292    fn set_config_rejects_invalid_upscale_and_leaves_detector_usable() {
293        let mut det = Detector::new(DetectorConfig::chess()).unwrap();
294        let original_upscale = det.config().upscale;
295
296        let bad = DetectorConfig::chess().with_upscale(UpscaleConfig::fixed(5));
297        let err = det.set_config(bad).unwrap_err();
298        assert!(matches!(err, ChessError::Upscale(_)));
299
300        // The failed swap must not have mutated the active config.
301        assert_eq!(det.config().upscale, original_upscale);
302
303        // The detector must still be usable after the rejected swap.
304        let img = synthetic_board(64);
305        let corners = det.detect_u8(&img, 64, 64).unwrap();
306        assert!(!corners.is_empty());
307    }
308}