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, Roi};
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    /// Detect chessboard corners inside a rectangular region `roi` of a
180    /// raw 8-bit grayscale image.
181    ///
182    /// Returns the corners whose detected peak lies inside `roi`, each
183    /// refined and described exactly as [`Detector::detect_u8`] would: the
184    /// same configured refiner and the same orientation/descriptor stage.
185    /// Coordinates are in the full input-image pixel frame. Both detection
186    /// strategies (ChESS and Radon) are supported.
187    ///
188    /// # Single-scale only
189    ///
190    /// ROI detection is single-scale local detection by definition: the
191    /// [`multiscale`](DetectorConfig::multiscale) and
192    /// [`upscale`](DetectorConfig::upscale) sections of the active config
193    /// do **not** apply on this path. For whole-image detection — including
194    /// the coarse-to-fine pyramid and the pre-pipeline upscaling stage —
195    /// use [`Detector::detect_u8`] / [`Detector::detect`].
196    ///
197    /// # ROI clamping
198    ///
199    /// A `roi` extending past the image is clamped to the image bounds
200    /// (matching the multiscale ROI-carving behaviour). A fully
201    /// out-of-range or degenerate post-clamp `roi` returns `Ok(vec![])`,
202    /// not an error.
203    ///
204    /// # Parity with `detect_u8`
205    ///
206    /// Parity is defined against a **single-scale, non-upscaled**
207    /// [`Detector::detect_u8`] run — [`multiscale`](DetectorConfig::multiscale)
208    /// set to `SingleScale` and [`upscale`](DetectorConfig::upscale)
209    /// disabled, as in the [`DetectorConfig::chess`] and
210    /// [`DetectorConfig::radon`] presets. When a pyramid or upscale
211    /// section is active, `detect_u8` detects on resampled images that
212    /// this path never builds, so its output is not comparable
213    /// corner-for-corner.
214    ///
215    /// Against that run, every **ChESS** corner whose peak lies more
216    /// than `ring_radius + nms_radius` pixels inside the clamped `roi`
217    /// (on every side) is returned here with a **bit-identical
218    /// `response`** and a position that agrees to within floating-point
219    /// rounding (well under `1e-3` px). The integer peak detection is
220    /// exact; only the sub-pixel refinement rounds differently, because
221    /// it runs in the ROI-local coordinate frame — the same numerical
222    /// relationship a coarse-to-fine multiscale run has to a full-frame
223    /// single-scale run. The **Radon** strategy recomputes its response
224    /// over the carved patch (prefix-sum accumulation restarts at the
225    /// patch origin), so its parity is approximate: interior corners
226    /// reappear, but response values and subpixel positions carry a
227    /// small floating-point drift rather than being bit-exact. Corners
228    /// nearer the ROI edge — or nearer than the detector support to the
229    /// image border — may differ or be absent under either strategy.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`ChessError::DimensionMismatch`] if `img.len() != width *
234    /// height`. With the `ml-refiner` feature, returns
235    /// `ChessError::RoiRefinerUnsupported` when the configuration selects
236    /// the ML refiner: the ML refiner runs a whole-frame model pipeline
237    /// this path does not carry, and the refiner selection is never
238    /// silently downgraded — use [`Detector::detect_u8`] for ML refinement.
239    pub fn detect_u8_roi(
240        &mut self,
241        img: &[u8],
242        width: u32,
243        height: u32,
244        roi: Roi,
245    ) -> Result<Vec<CornerDescriptor>, ChessError> {
246        let src_w = width as usize;
247        let src_h = height as usize;
248        let expected = src_w * src_h;
249        if img.len() != expected {
250            return Err(ChessError::DimensionMismatch {
251                expected,
252                actual: img.len(),
253            });
254        }
255
256        // The ML refiner has no ROI-path plumbing; refuse rather than
257        // silently downgrading to the core default refiner.
258        #[cfg(feature = "ml-refiner")]
259        if Self::is_ml_refiner(&self.cfg) {
260            return Err(ChessError::RoiRefinerUnsupported);
261        }
262
263        let view =
264            ImageView::from_u8_slice(src_w, src_h, img).expect("dimensions were checked above");
265        Ok(multiscale::detect_roi_with_buffers(
266            view,
267            roi,
268            &self.cfg,
269            &mut self.chess_buffers,
270            &mut self.radon_buffers,
271        ))
272    }
273
274    /// Detect chessboard corners inside a rectangular region `roi` of an
275    /// [`image::GrayImage`].
276    ///
277    /// See [`Detector::detect_u8_roi`] for the ROI contract, the
278    /// single-scale caveat, the clamping behaviour, and the parity
279    /// guarantee.
280    ///
281    /// # Errors
282    ///
283    /// Same as [`Detector::detect_u8_roi`].
284    #[cfg(feature = "image")]
285    pub fn detect_roi(
286        &mut self,
287        img: &image::GrayImage,
288        roi: Roi,
289    ) -> Result<Vec<CornerDescriptor>, ChessError> {
290        self.detect_u8_roi(img.as_raw(), img.width(), img.height(), roi)
291    }
292
293    /// Borrow a detector-bound diagnostics accessor.
294    ///
295    /// The returned [`DetectorDiagnostics`](crate::diagnostics::DetectorDiagnostics)
296    /// exposes intermediate
297    /// detector outputs — the dense ChESS response map and the Radon
298    /// heatmap — sourced from this detector's already-configured
299    /// [`DetectorConfig`], so a caller holding a configured `Detector`
300    /// need not re-supply a config to obtain diagnostic data.
301    ///
302    /// This is the detector-bound half of the diagnostics channel; the
303    /// free functions in [`crate::diagnostics`] serve stateless
304    /// callers. Both share the same **opt-in, looser-stability**
305    /// contract: diagnostic outputs are advisory and may change as the
306    /// detector internals evolve, independently of the
307    /// [`Detector::detect`] result contract.
308    pub fn diagnostics(&self) -> crate::diagnostics::DetectorDiagnostics<'_> {
309        crate::diagnostics::DetectorDiagnostics::new(self)
310    }
311
312    fn detect_view_inner(
313        cfg: &DetectorConfig,
314        pyramid: &mut PyramidBuffers,
315        chess_buffers: &mut ChessBuffers,
316        radon_buffers: &mut RadonBuffers,
317        #[cfg(feature = "ml-refiner")] ml_state: &mut Option<ml_refiner::MlRefinerState>,
318        #[cfg(feature = "ml-refiner")] ml_params: &ml_refiner::MlRefinerParams,
319        view: ImageView<'_>,
320    ) -> Vec<CornerDescriptor> {
321        #[cfg(feature = "ml-refiner")]
322        if Self::is_ml_refiner(cfg) {
323            if ml_state.is_none() {
324                let fallback = chess_corners_core::RefinerKind::CenterOfMass(
325                    chess_corners_core::CenterOfMassConfig::default(),
326                );
327                *ml_state = Some(ml_refiner::MlRefinerState::new(ml_params, &fallback));
328            }
329            let state = ml_state.as_mut().expect("ml_state initialised above");
330            return multiscale::detect_with_ml(
331                view,
332                cfg,
333                pyramid,
334                chess_buffers,
335                radon_buffers,
336                ml_params,
337                state,
338            );
339        }
340
341        multiscale::detect_with_buffers(view, cfg, pyramid, chess_buffers, radon_buffers)
342    }
343
344    /// Whether the active config selects the ML refiner. Only true on
345    /// the ChESS path, since the Radon strategy carries a separate
346    /// refiner enum that has no ML variant.
347    #[cfg(feature = "ml-refiner")]
348    #[inline]
349    fn is_ml_refiner(cfg: &DetectorConfig) -> bool {
350        matches!(
351            &cfg.strategy,
352            crate::DetectionStrategy::Chess(c) if matches!(c.refiner, crate::ChessRefiner::Ml)
353        )
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::UpscaleConfig;
361    use chess_corners_testutil::{aa_chessboard, gaussian_blur};
362
363    fn synthetic_board(size: usize) -> Vec<u8> {
364        aa_chessboard(size, 12, (0.0, 0.0), 20, 220)
365    }
366
367    fn roi(x0: usize, y0: usize, x1: usize, y1: usize) -> Roi {
368        Roi::new(x0, y0, x1, y1).expect("valid roi")
369    }
370
371    fn bit_eq(a: f32, b: f32) -> bool {
372        a.to_bits() == b.to_bits()
373    }
374
375    #[test]
376    fn detect_u8_reports_dimension_mismatch() {
377        let mut det = Detector::with_default();
378        let img = vec![0u8; 10];
379        let err = det.detect_u8(&img, 8, 8).unwrap_err();
380        match err {
381            ChessError::DimensionMismatch { expected, actual } => {
382                assert_eq!(expected, 64);
383                assert_eq!(actual, 10);
384            }
385            other => panic!("expected ChessError::DimensionMismatch, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn set_config_valid_swap_changes_detection_outcome() {
391        let size = 96usize;
392        let img = synthetic_board(size);
393
394        let mut det = Detector::new(DetectorConfig::chess().with_threshold(30.0)).unwrap();
395        let low_threshold = det.detect_u8(&img, size as u32, size as u32).unwrap();
396        assert!(
397            !low_threshold.is_empty(),
398            "expected corners at the default threshold"
399        );
400
401        det.set_config(DetectorConfig::chess().with_threshold(5000.0))
402            .expect("valid upscale config");
403        let high_threshold = det.detect_u8(&img, size as u32, size as u32).unwrap();
404        assert!(
405            high_threshold.len() < low_threshold.len(),
406            "raising the response threshold far above real corner strengths \
407             must suppress detections: low={} high={}",
408            low_threshold.len(),
409            high_threshold.len()
410        );
411    }
412
413    #[test]
414    fn set_config_rejects_invalid_upscale_and_leaves_detector_usable() {
415        let mut det = Detector::new(DetectorConfig::chess()).unwrap();
416        let original_upscale = det.config().upscale;
417
418        let bad = DetectorConfig::chess().with_upscale(UpscaleConfig::fixed(5));
419        let err = det.set_config(bad).unwrap_err();
420        assert!(matches!(err, ChessError::Upscale(_)));
421
422        // The failed swap must not have mutated the active config.
423        assert_eq!(det.config().upscale, original_upscale);
424
425        // The detector must still be usable after the rejected swap.
426        let img = synthetic_board(64);
427        let corners = det.detect_u8(&img, 64, 64).unwrap();
428        assert!(!corners.is_empty());
429    }
430
431    #[test]
432    fn detect_u8_roi_reports_dimension_mismatch() {
433        let mut det = Detector::with_default();
434        let img = vec![0u8; 10];
435        let err = det.detect_u8_roi(&img, 8, 8, roi(0, 0, 4, 4)).unwrap_err();
436        assert!(matches!(err, ChessError::DimensionMismatch { .. }));
437    }
438
439    /// Full-frame ChESS corners lying more than `ring_radius + nms_radius`
440    /// (= 7 for the default ring) inside an interior ROI must reappear from
441    /// `detect_u8_roi` with a bit-identical response and a position that
442    /// matches to floating-point rounding. The inflated patch reproduces
443    /// the full-frame response bit-for-bit, so the integer peak and its
444    /// response strength are exact; the sub-pixel centroid runs in the
445    /// ROI-local frame and rounds by ~1 ULP relative to the global-frame
446    /// centroid (the same effect the coarse-to-fine path exhibits).
447    #[test]
448    fn chess_roi_matches_full_frame_on_interior() {
449        let size = 100usize;
450        let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
451        let mut det = Detector::new(DetectorConfig::chess()).unwrap();
452
453        let full = det.detect_u8(&img, size as u32, size as u32).unwrap();
454        let sub = det
455            .detect_u8_roi(&img, size as u32, size as u32, roi(20, 20, 80, 80))
456            .unwrap();
457
458        // ring_radius(5) + nms_radius(2) for the default ChESS config.
459        let border = 7.0f32;
460        let (rx0, ry0, rx1, ry1) = (20.0f32, 20.0, 80.0, 80.0);
461        let mut checked = 0usize;
462        for fc in &full {
463            let strictly_inside = fc.x > rx0 + border
464                && fc.x < rx1 - border
465                && fc.y > ry0 + border
466                && fc.y < ry1 - border;
467            if !strictly_inside {
468                continue;
469            }
470            // Response (raw peak strength) is frame-independent → exact.
471            // Position agrees to floating-point rounding of the ROI shift.
472            let found = sub.iter().any(|sc| {
473                bit_eq(sc.response, fc.response)
474                    && (sc.x - fc.x).abs() < 1e-3
475                    && (sc.y - fc.y).abs() < 1e-3
476            });
477            assert!(
478                found,
479                "interior corner ({:.4},{:.4}) r={:.4} missing or outside parity tolerance in ROI result",
480                fc.x, fc.y, fc.response
481            );
482            checked += 1;
483        }
484        assert!(
485            checked >= 4,
486            "expected several strictly-interior corners to compare, got {checked}"
487        );
488    }
489
490    /// A ROI whose top-left is far from the origin must return corners in
491    /// the global (base-image) frame. If the coordinates were patch-local
492    /// they would not coincide with any full-frame corner position.
493    #[test]
494    fn chess_roi_coordinates_are_global() {
495        let size = 100usize;
496        let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
497        let mut det = Detector::new(DetectorConfig::chess()).unwrap();
498
499        let full = det.detect_u8(&img, size as u32, size as u32).unwrap();
500        let sub = det
501            .detect_u8_roi(&img, size as u32, size as u32, roi(48, 48, 92, 92))
502            .unwrap();
503
504        assert!(!sub.is_empty(), "expected corners in the interior ROI");
505        for sc in &sub {
506            let matched = full
507                .iter()
508                .any(|fc| (fc.x - sc.x).abs() < 0.01 && (fc.y - sc.y).abs() < 0.01);
509            assert!(
510                matched,
511                "ROI corner ({:.3},{:.3}) does not match any full-frame (global) corner",
512                sc.x, sc.y
513            );
514        }
515    }
516
517    #[test]
518    fn roi_edges_and_out_of_bounds() {
519        let size = 100usize;
520        let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
521        let mut det = Detector::new(DetectorConfig::chess()).unwrap();
522        let w = size as u32;
523
524        // ROI flush against the top-left image border: no panic, still
525        // finds interior corners away from the border.
526        let border_roi = det.detect_u8_roi(&img, w, w, roi(0, 0, 45, 45)).unwrap();
527        assert!(
528            !border_roi.is_empty(),
529            "expected corners in a border-touching ROI"
530        );
531
532        // ROI smaller than the detector support: Ok(empty), no panic.
533        let tiny = det.detect_u8_roi(&img, w, w, roi(50, 50, 53, 53)).unwrap();
534        assert!(tiny.is_empty(), "sub-support ROI must yield no corners");
535
536        // Partially out-of-range ROI clamps to the image bounds (must not
537        // panic or error).
538        let _clamped = det
539            .detect_u8_roi(&img, w, w, roi(80, 80, 300, 300))
540            .unwrap();
541
542        // Fully out-of-range ROI is degenerate after clamping → empty.
543        let gone = det
544            .detect_u8_roi(&img, w, w, roi(150, 150, 300, 300))
545            .unwrap();
546        assert!(
547            gone.is_empty(),
548            "fully out-of-range ROI must yield no corners"
549        );
550    }
551
552    #[test]
553    fn roi_respects_orientation_config() {
554        let size = 100usize;
555        let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
556        let w = size as u32;
557        let region = roi(24, 24, 84, 84);
558
559        let mut with_axes = Detector::new(DetectorConfig::chess()).unwrap();
560        let described = with_axes.detect_u8_roi(&img, w, w, region).unwrap();
561        assert!(!described.is_empty());
562        assert!(
563            described.iter().all(|c| c.axes.is_some()),
564            "default config must attach orientation axes"
565        );
566
567        let mut no_axes = Detector::new(DetectorConfig::chess().without_orientation()).unwrap();
568        let bare = no_axes.detect_u8_roi(&img, w, w, region).unwrap();
569        assert!(!bare.is_empty());
570        assert!(
571            bare.iter().all(|c| c.axes.is_none()),
572            "without_orientation() must skip the orientation fit"
573        );
574    }
575
576    #[test]
577    fn roi_detection_is_deterministic() {
578        let size = 100usize;
579        let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
580        let w = size as u32;
581        let region = roi(20, 20, 80, 80);
582        let mut det = Detector::new(DetectorConfig::chess()).unwrap();
583
584        let a = det.detect_u8_roi(&img, w, w, region).unwrap();
585        let b = det.detect_u8_roi(&img, w, w, region).unwrap();
586        assert_eq!(a.len(), b.len(), "corner count must be stable");
587        for (ca, cb) in a.iter().zip(&b) {
588            assert!(
589                bit_eq(ca.x, cb.x) && bit_eq(ca.y, cb.y) && bit_eq(ca.response, cb.response),
590                "ROI detection must be bit-deterministic"
591            );
592        }
593    }
594
595    /// Radon smoke: ROI detection is non-empty and every ROI corner lies
596    /// close to a full-frame corner from the same region. Radon computes
597    /// its response on the copied ROI, so patch and full-frame subpixel
598    /// positions agree only within a small tolerance (not bit-exact).
599    #[test]
600    fn radon_roi_smoke_matches_full_frame() {
601        let size = 129usize;
602        let mut img = aa_chessboard(size, 12, (0.4, 0.6), 30, 220);
603        gaussian_blur(&mut img, size, 1.2);
604        let w = size as u32;
605        let mut det = Detector::new(DetectorConfig::radon()).unwrap();
606
607        let full = det.detect_u8(&img, w, w).unwrap();
608        let sub = det.detect_u8_roi(&img, w, w, roi(36, 36, 96, 96)).unwrap();
609
610        assert!(!sub.is_empty(), "Radon ROI detection must find corners");
611        for sc in &sub {
612            let near = full
613                .iter()
614                .any(|fc| (fc.x - sc.x).abs() < 2.0 && (fc.y - sc.y).abs() < 2.0);
615            assert!(
616                near,
617                "Radon ROI corner ({:.2},{:.2}) has no nearby full-frame corner",
618                sc.x, sc.y
619            );
620        }
621    }
622
623    /// A Radon corner whose integer peak sits exactly ON the ROI
624    /// boundary (subpixel position just inside it) must still be
625    /// returned. The carved patch has to reserve the extra pixel
626    /// `detect_peaks_from_radon` keeps for its 3-point peak fit —
627    /// with a margin of only `ray + nms` the boundary peak lands in
628    /// the excluded patch border and silently disappears (regression
629    /// for the `RadonDetector::roi_border` off-by-one).
630    #[test]
631    fn radon_roi_keeps_boundary_peaks() {
632        let size = 129usize;
633        let mut img = aa_chessboard(size, 12, (0.4, 0.6), 30, 220);
634        gaussian_blur(&mut img, size, 1.2);
635        let w = size as u32;
636
637        for upsample in [1u32, 2] {
638            let cfg = DetectorConfig::radon().with_radon(|r| r.image_upsample = upsample);
639            let mut det = Detector::new(cfg).unwrap();
640            let full = det.detect_u8(&img, w, w).unwrap();
641            assert!(!full.is_empty(), "full-frame Radon must find corners");
642
643            // Put an ROI boundary exactly on each corner's integer
644            // peak, on the side that keeps the subpixel position
645            // inside the half-open ROI: the min edge when the position
646            // sits right of / below the peak, the max edge otherwise.
647            // Corners whose position is nearly centred on the peak are
648            // skipped — their half-open membership is ambiguous.
649            let mut checked = 0usize;
650            for fc in &full {
651                let px = fc.x.round();
652                let py = fc.y.round();
653                let (fx, fy) = (fc.x - px, fc.y - py);
654                if fx.abs() < 0.05 || fy.abs() < 0.05 {
655                    continue;
656                }
657                let (px, py) = (px as usize, py as usize);
658                let (x0, x1) = if fx > 0.0 {
659                    (px, (px + 50).min(size))
660                } else {
661                    ((px + 1).saturating_sub(50), px + 1)
662                };
663                let (y0, y1) = if fy > 0.0 {
664                    (py, (py + 50).min(size))
665                } else {
666                    ((py + 1).saturating_sub(50), py + 1)
667                };
668                let region = roi(x0, y0, x1, y1);
669                let sub = det.detect_u8_roi(&img, w, w, region).unwrap();
670                let found = sub
671                    .iter()
672                    .any(|sc| (sc.x - fc.x).abs() < 0.5 && (sc.y - fc.y).abs() < 0.5);
673                assert!(
674                    found,
675                    "upsample={upsample}: boundary corner ({:.3},{:.3}) dropped from ROI ({x0},{y0})-({x1},{y1})",
676                    fc.x, fc.y
677                );
678                checked += 1;
679            }
680            assert!(
681                checked >= 3,
682                "upsample={upsample}: expected several boundary-peak corners to check, got {checked}"
683            );
684        }
685    }
686
687    #[cfg(feature = "ml-refiner")]
688    #[test]
689    fn roi_rejects_ml_refiner_without_silent_downgrade() {
690        let size = 64usize;
691        let img = synthetic_board(size);
692        let cfg = DetectorConfig::chess().with_chess(|c| c.refiner = crate::ChessRefiner::Ml);
693        let mut det = Detector::new(cfg).unwrap();
694        let err = det
695            .detect_u8_roi(&img, size as u32, size as u32, roi(10, 10, 50, 50))
696            .unwrap_err();
697        assert!(matches!(err, ChessError::RoiRefinerUnsupported));
698    }
699}