Skip to main content

chess_corners_core/detect/
dense.rs

1//! Two-stage dense corner detector abstraction.
2//!
3//! [`DenseDetector`] is the contract the multiscale orchestrator drives
4//! over: each implementor computes a dense per-pixel response over an
5//! [`ImageView`] (stage 1) and extracts subpixel corner peaks from it
6//! (stage 2). Image-domain subpixel refinement (center-of-mass,
7//! Förstner, saddle-point, …) is **not** part of this trait — it runs
8//! detector-agnostically via
9//! [`crate::refine_corners_on_image`].
10//!
11//! Two zero-sized implementors live alongside the trait:
12//!
13//! - [`ChessDetector`] — wraps the ChESS response kernel
14//!   ([`crate::chess_response_u8`]) and the
15//!   threshold + NMS + cluster-filter stage
16//!   ([`crate::detect_peaks_from_response_with_refine_radius`]).
17//! - [`RadonDetector`] — wraps the whole-image Duda-Frese Radon
18//!   response ([`crate::radon_response_u8`]) and the
19//!   threshold + NMS + 3-point Gaussian peak-fit stage
20//!   ([`crate::detect_peaks_from_radon`]).
21//!
22//! The free functions named above remain public; the trait is an
23//! additive uniform interface, not a replacement.
24//!
25//! # Toolchain note
26//!
27//! [`DenseDetector::Response`] is a generic associated type (GAT);
28//! downstream consumers need Rust 1.65 or newer. This workspace
29//! already pins nightly via `rust-toolchain.toml`, so the trait is
30//! always available here.
31
32use super::{
33    chess::{
34        detect::{detect_peaks_from_response_with_refine_radius, refine_corners_on_image},
35        response::{chess_response_u8, chess_response_u8_patch, Roi},
36    },
37    radon::{
38        detect_peaks_from_radon, radon_response_u8, RadonBuffers, RadonDetectorParams,
39        RadonResponseView,
40    },
41    Corner,
42};
43use crate::imageview::ImageView;
44use crate::refine::CornerRefiner;
45use crate::{ChessParams, ResponseMap};
46
47/// Sealing supertrait for [`DenseDetector`]. Implemented only for the
48/// in-crate detector markers, so downstream crates cannot add their own
49/// [`DenseDetector`] implementations.
50mod private {
51    pub trait Sealed {}
52}
53
54/// Two-stage dense corner detector contract.
55///
56/// Implementors compute a dense per-pixel response over the input
57/// image (`compute_response`, stage 1) and extract subpixel corner
58/// peaks from it (`detect_corners`, stage 2). The two stages share
59/// reusable scratch through [`Self::Buffers`] so a multiscale
60/// orchestrator can amortise allocations across frames.
61///
62/// Subpixel refinement on the *input image* (Förstner, saddle-point,
63/// center-of-mass, …) is NOT part of this trait — that runs as a
64/// post-detection stage via
65/// [`crate::refine_corners_on_image`], which is
66/// detector-agnostic.
67///
68/// # Toolchain
69///
70/// Uses generic associated types ([`Self::Response`]); downstream
71/// consumers require Rust 1.65 or newer.
72///
73/// # Stability
74///
75/// This trait is **sealed** via a private supertrait bound and cannot
76/// be implemented outside this crate. The only implementors are
77/// [`ChessDetector`] and [`RadonDetector`]; it is not a public
78/// extension point, so its method set may evolve without a breaking
79/// release.
80pub trait DenseDetector: private::Sealed {
81    /// Detector-specific tuning parameters.
82    type Params;
83    /// Reusable scratch buffers. Allocated once via
84    /// [`Default::default`] and reused across `compute_response`
85    /// calls to avoid per-frame allocation.
86    type Buffers: Default;
87    /// Native response representation. May be owned (a borrow of an
88    /// owned [`ResponseMap`] in [`Self::Buffers`]) or a transient
89    /// view ([`RadonResponseView`]) over the same scratch. The
90    /// borrow lifetime ties the response back to the buffers that
91    /// produced it.
92    type Response<'a>
93    where
94        Self: 'a,
95        Self::Buffers: 'a;
96
97    /// Compute the dense response over `view`, writing into
98    /// `buffers` and returning a borrowed handle.
99    fn compute_response<'a>(
100        &self,
101        view: ImageView<'_>,
102        params: &Self::Params,
103        buffers: &'a mut Self::Buffers,
104    ) -> Self::Response<'a>;
105
106    /// Extract corner peaks from `response`. Positions are in the
107    /// input-image frame (the same frame `view` was passed in at
108    /// [`Self::compute_response`]).
109    ///
110    /// `refine_border` is an *additional* base-image-pixel margin
111    /// the implementor must keep around each accepted peak so that a
112    /// downstream image-domain refiner with that patch half-width
113    /// has full support. Passing `0` selects "no extra refiner
114    /// margin" — appropriate when refinement happens through a
115    /// separate stage that does its own bounds check. Whether the
116    /// implementor extends its own border (ChESS) or ignores the
117    /// argument (Radon — its NMS + Gaussian peak-fit already enforce
118    /// the support needed) is detector-specific.
119    fn detect_corners(
120        &self,
121        response: &Self::Response<'_>,
122        params: &Self::Params,
123        refine_border: i32,
124    ) -> Vec<Corner>;
125
126    /// Compute the dense response over the sub-rectangle
127    /// `[x0..x1) × [y0..y1)` of `base`, where the ROI is given as the
128    /// tuple `(x0, y0, x1, y1)`. The returned response is sized to
129    /// the ROI; any [`Corner`] positions produced from it by
130    /// [`Self::detect_corners`] are **patch-local** (origin = ROI's
131    /// top-left), and the caller is responsible for shifting them
132    /// back into base-image coordinates by adding `(x0, y0)`.
133    ///
134    /// Implementors may reach outside the ROI to compute responses
135    /// near its borders (so that an ROI tile produces values
136    /// numerically identical to the full-frame response on the
137    /// overlapping interior). The shared `buffers` is reused across
138    /// calls.
139    fn compute_response_patch<'a>(
140        &self,
141        base: ImageView<'_>,
142        roi: (i32, i32, i32, i32),
143        params: &Self::Params,
144        buffers: &'a mut Self::Buffers,
145    ) -> Self::Response<'a>;
146
147    /// Detector-specific safety border (in **base-image pixels**) the
148    /// orchestrator must keep around each seed when carving an ROI.
149    /// Typically the sum of the detector's response-support radius
150    /// (e.g. ChESS ring radius, Radon ray radius) and its NMS
151    /// half-window — i.e. the minimum margin needed for
152    /// [`Self::compute_response_patch`] + [`Self::detect_corners`] to
153    /// return a non-trivial peak inside the ROI.
154    fn roi_border(&self, params: &Self::Params) -> i32;
155
156    /// Apply a detector-appropriate image-domain refinement step to
157    /// the peaks produced by [`Self::detect_corners`].
158    ///
159    /// The default subpixel refiners ([`crate::Refiner`]
160    /// variants) expect a [`ResponseMap`] (center-of-mass,
161    /// Förstner) or an image patch (saddle-point, Radon-peak) keyed
162    /// to the detector's response. ChESS forwards its
163    /// [`ResponseMap`] straight through; Radon's
164    /// [`RadonResponseView`] does not fit the [`ResponseMap`]
165    /// contract (working-resolution layout, different coordinate
166    /// frame than the peak positions), so the Radon implementor
167    /// keeps the 3-point Gaussian peak fit from
168    /// [`Self::detect_corners`] as the subpixel position and skips
169    /// further refinement.
170    fn refine_peaks_on_image(
171        &self,
172        corners: Vec<Corner>,
173        image: ImageView<'_>,
174        response: &Self::Response<'_>,
175        refiner: &mut dyn CornerRefiner,
176    ) -> Vec<Corner>;
177
178    /// Whether [`Self::refine_peaks_on_image`] actually consumes the
179    /// orchestrator-supplied refiner. When `false`, the orchestrator
180    /// must not include the refiner's patch radius in the per-seed
181    /// ROI margin — otherwise a no-op refiner choice would still
182    /// shrink the valid seed area near the image border (a tunable
183    /// silently coupling to an unused setting).
184    ///
185    /// Default `true` matches the ChESS-style "refine on image"
186    /// contract; the Radon impl returns `false` because its
187    /// [`Self::refine_peaks_on_image`] is a no-op.
188    fn refines_on_image(&self) -> bool {
189        true
190    }
191}
192
193/// Reusable scratch for [`ChessDetector`]. Wraps an owned
194/// [`ResponseMap`]; the ChESS response kernel currently allocates its
195/// output, and this struct keeps the latest map alive so the trait's
196/// `Response<'a> = &'a ResponseMap` can borrow it across the two
197/// stages.
198#[derive(Debug, Default)]
199#[non_exhaustive]
200pub struct ChessBuffers {
201    /// Dense ChESS response from the most recent
202    /// [`ChessDetector::compute_response`] call.
203    pub response: ResponseMap,
204}
205
206/// Zero-sized [`DenseDetector`] implementor for the ChESS kernel.
207///
208/// Wraps the canonical 16-sample ring response
209/// ([`crate::chess_response_u8`]) and the
210/// threshold + NMS + cluster-filter peak detector
211/// ([`crate::detect_peaks_from_response_with_refine_radius`]). Subpixel
212/// refinement (center-of-mass, Förstner, saddle-point) is a separate
213/// detector-agnostic stage; see
214/// [`crate::refine_corners_on_image`].
215#[derive(Debug, Default, Clone, Copy)]
216pub struct ChessDetector;
217
218impl private::Sealed for ChessDetector {}
219
220impl DenseDetector for ChessDetector {
221    type Params = ChessParams;
222    type Buffers = ChessBuffers;
223    type Response<'a> = &'a ResponseMap;
224
225    fn compute_response<'a>(
226        &self,
227        view: ImageView<'_>,
228        params: &Self::Params,
229        buffers: &'a mut Self::Buffers,
230    ) -> Self::Response<'a> {
231        // chess_response_u8 returns an owned ResponseMap. Swap it into
232        // the buffer so the borrow returned to the caller lives as long
233        // as `buffers`. The previous map (likely from the prior frame)
234        // is dropped; its backing Vec capacity is reclaimed at next
235        // allocation. A future `chess_response_u8_into(.., &mut Vec)`
236        // helper could keep the allocation, but the snapshot-pinned
237        // numerical contract has to stay bit-identical first.
238        buffers.response = chess_response_u8(view.data, view.width, view.height, params);
239        &buffers.response
240    }
241
242    fn detect_corners(
243        &self,
244        response: &Self::Response<'_>,
245        params: &Self::Params,
246        refine_border: i32,
247    ) -> Vec<Corner> {
248        detect_peaks_from_response_with_refine_radius(response, params, refine_border)
249    }
250
251    fn compute_response_patch<'a>(
252        &self,
253        base: ImageView<'_>,
254        roi: (i32, i32, i32, i32),
255        params: &Self::Params,
256        buffers: &'a mut Self::Buffers,
257    ) -> Self::Response<'a> {
258        // ChESS has a dedicated patch kernel that reaches outside the
259        // ROI to compute response values near its borders, so the
260        // patch output overlaps numerically with the full-frame
261        // response inside the ROI. Copy-then-compute would break that
262        // invariant (and the snapshot regression that pins it).
263        let (x0, y0, x1, y1) = roi;
264        let roi_obj = Roi::new(
265            x0.max(0) as usize,
266            y0.max(0) as usize,
267            x1.max(0) as usize,
268            y1.max(0) as usize,
269        );
270        buffers.response = match roi_obj {
271            Some(r) => chess_response_u8_patch(base.data, base.width, base.height, params, r),
272            None => ResponseMap::default(),
273        };
274        &buffers.response
275    }
276
277    fn roi_border(&self, params: &Self::Params) -> i32 {
278        (params.ring_radius() as i32 + params.nms_radius as i32).max(0)
279    }
280
281    fn refine_peaks_on_image(
282        &self,
283        corners: Vec<Corner>,
284        image: ImageView<'_>,
285        response: &Self::Response<'_>,
286        refiner: &mut dyn CornerRefiner,
287    ) -> Vec<Corner> {
288        // ChESS's response IS a `ResponseMap`, so we can forward it
289        // straight through. This restores the bit-for-bit numerical
290        // behaviour of the legacy fused path
291        // (`detect_corners_from_response_with_refiner`), which always
292        // passed `Some(resp)` to the refiner.
293        refine_corners_on_image(corners, Some(image), Some(response), refiner)
294    }
295}
296
297/// Zero-sized [`DenseDetector`] implementor for the whole-image
298/// Duda-Frese Radon kernel.
299///
300/// Wraps [`crate::radon_response_u8`] (SAT-based dense
301/// response) and [`crate::detect_peaks_from_radon`]
302/// (threshold, NMS, 3-point Gaussian peak-fit on the working-resolution
303/// map). Output [`Corner`] positions are in the input-image frame: the
304/// Radon peak detector divides by `image_upsample` internally.
305#[derive(Debug, Default, Clone, Copy)]
306pub struct RadonDetector;
307
308impl private::Sealed for RadonDetector {}
309
310impl DenseDetector for RadonDetector {
311    type Params = RadonDetectorParams;
312    type Buffers = RadonBuffers;
313    type Response<'a> = RadonResponseView<'a>;
314
315    fn compute_response<'a>(
316        &self,
317        view: ImageView<'_>,
318        params: &Self::Params,
319        buffers: &'a mut Self::Buffers,
320    ) -> Self::Response<'a> {
321        radon_response_u8(view.data, view.width, view.height, params, buffers)
322    }
323
324    fn detect_corners(
325        &self,
326        response: &Self::Response<'_>,
327        params: &Self::Params,
328        _refine_border: i32,
329    ) -> Vec<Corner> {
330        // Radon's working-resolution border already absorbs the
331        // ray_radius + nms + 3-point-fit margin internally, and the
332        // returned peak positions are in input-image coordinates
333        // (post `image_upsample` division). An additional refiner
334        // border argument expressed in *base-image pixels* doesn't
335        // map cleanly onto the working-resolution peak detector and
336        // is ignored.
337        detect_peaks_from_radon(response, params)
338    }
339
340    fn compute_response_patch<'a>(
341        &self,
342        base: ImageView<'_>,
343        roi: (i32, i32, i32, i32),
344        params: &Self::Params,
345        buffers: &'a mut Self::Buffers,
346    ) -> Self::Response<'a> {
347        // Radon has no ROI-border tricks (the SAT-based kernel reads
348        // only inside its input slice), so a copy-then-compute path
349        // is numerically equivalent to a hypothetical patch kernel.
350        //
351        // The ROI patch is allocated locally; `radon_response_u8`
352        // borrows `&mut buffers` for the SAT / response scratch, and
353        // returning the view ties that borrow to the caller's `'a`,
354        // so we cannot also keep the ROI pixels inside `buffers`.
355        // The Vec is small (ROI pixel count, hundreds of bytes in
356        // typical multiscale use) and the allocation cost is
357        // dominated by the SAT build per ROI.
358        let (x0, y0, x1, y1) = roi;
359        let x0 = (x0.max(0) as usize).min(base.width);
360        let y0 = (y0.max(0) as usize).min(base.height);
361        let x1 = (x1.max(0) as usize).min(base.width);
362        let y1 = (y1.max(0) as usize).min(base.height);
363        if x1 <= x0 || y1 <= y0 {
364            // Produce a degenerate 0×0 response that detect_corners
365            // will see as empty.
366            return radon_response_u8(&[], 0, 0, params, buffers);
367        }
368        let roi_w = x1 - x0;
369        let roi_h = y1 - y0;
370        let mut scratch = vec![0u8; roi_w * roi_h];
371        for py in 0..roi_h {
372            let src_off = (y0 + py) * base.width + x0;
373            let dst_off = py * roi_w;
374            scratch[dst_off..dst_off + roi_w].copy_from_slice(&base.data[src_off..src_off + roi_w]);
375        }
376        radon_response_u8(&scratch, roi_w, roi_h, params, buffers)
377    }
378
379    fn roi_border(&self, params: &Self::Params) -> i32 {
380        // Radon operates at working resolution; the ROI is sliced in
381        // base-image pixels, so the border-in-base-pixels accounts
382        // for the working-resolution support divided by the
383        // upsample. ray_radius and nms_radius are in working pixels.
384        let up = params.image_upsample_clamped() as i32;
385        let ray = params.ray_radius_clamped() as i32;
386        let nms = params.nms_radius as i32;
387        ((ray + nms + up - 1) / up).max(0)
388    }
389
390    fn refine_peaks_on_image(
391        &self,
392        corners: Vec<Corner>,
393        _image: ImageView<'_>,
394        _response: &Self::Response<'_>,
395        _refiner: &mut dyn CornerRefiner,
396    ) -> Vec<Corner> {
397        // Radon's `detect_corners` already runs a 3-point Gaussian
398        // peak fit on the response map, which IS the subpixel
399        // refinement step for this detector. The default
400        // `Refiner::CenterOfMass` consumes a `ResponseMap` keyed to
401        // the seed's coordinate frame; Radon's response is a
402        // `RadonResponseView` at working resolution (different
403        // coordinate frame and different element layout), so wiring
404        // the refiner here would either reject every corner (if we
405        // pass `None`) or silently sample the wrong frame. We keep
406        // the peaks as the detector emitted them.
407        corners
408    }
409
410    fn refines_on_image(&self) -> bool {
411        // Paired with the no-op `refine_peaks_on_image` above: the
412        // orchestrator must not pad the per-seed ROI with the
413        // refiner's patch radius, since the refiner is never
414        // consulted on the Radon path.
415        false
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::detect::radon::test_fixtures::synthetic_chessboard_aa;
423
424    /// Smoke test: both detectors return at least one corner on the
425    /// shared synthetic anti-aliased chessboard fixture, end to end
426    /// through the [`DenseDetector`] trait. The snapshot test in the
427    /// facade is the real numerical regression gate; this only
428    /// guards the trait wiring.
429    #[test]
430    fn dense_detector_trait_drives_both_implementors() {
431        const SIZE: usize = 65;
432        const CELL: usize = 8;
433        let img = synthetic_chessboard_aa(SIZE, CELL, (32.35, 32.8), 30, 230);
434        let view = ImageView::from_u8_slice(SIZE, SIZE, &img).expect("view");
435
436        // ChESS path.
437        let chess_params = ChessParams::default();
438        let mut chess_buffers = ChessBuffers::default();
439        let chess = ChessDetector;
440        let resp = chess.compute_response(view, &chess_params, &mut chess_buffers);
441        let chess_corners = chess.detect_corners(&resp, &chess_params, 0);
442        assert!(
443            !chess_corners.is_empty(),
444            "ChessDetector returned no corners on synthetic chessboard"
445        );
446
447        // Radon path.
448        let radon_params = RadonDetectorParams {
449            image_upsample: 2,
450            ..RadonDetectorParams::default()
451        };
452        let mut radon_buffers = RadonBuffers::default();
453        let radon = RadonDetector;
454        let resp = radon.compute_response(view, &radon_params, &mut radon_buffers);
455        let radon_corners = radon.detect_corners(&resp, &radon_params, 0);
456        assert!(
457            !radon_corners.is_empty(),
458            "RadonDetector returned no corners on synthetic chessboard"
459        );
460    }
461
462    /// A second smoke test that pins the buffer-reuse semantics: a
463    /// fresh `ChessBuffers::default()` must produce a valid response
464    /// before any explicit initialisation, and a second call on the
465    /// same buffer must continue to work.
466    #[test]
467    fn chess_buffers_default_supports_first_use_and_reuse() {
468        const SIZE: usize = 33;
469        const CELL: usize = 6;
470        let img_a = synthetic_chessboard_aa(SIZE, CELL, (16.4, 16.1), 30, 230);
471        let img_b = synthetic_chessboard_aa(SIZE, CELL, (16.7, 16.2), 30, 230);
472        let view_a = ImageView::from_u8_slice(SIZE, SIZE, &img_a).expect("view");
473        let view_b = ImageView::from_u8_slice(SIZE, SIZE, &img_b).expect("view");
474        let params = ChessParams::default();
475        let mut buffers = ChessBuffers::default();
476        let chess = ChessDetector;
477
478        let resp_a = chess.compute_response(view_a, &params, &mut buffers);
479        let n_a = chess.detect_corners(&resp_a, &params, 0).len();
480        assert!(n_a > 0);
481
482        let resp_b = chess.compute_response(view_b, &params, &mut buffers);
483        let n_b = chess.detect_corners(&resp_b, &params, 0).len();
484        assert!(n_b > 0);
485    }
486}