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