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, ¶ms, &mut buffers);
479 let n_a = chess.detect_corners(&resp_a, ¶ms, 0).len();
480 assert!(n_a > 0);
481
482 let resp_b = chess.compute_response(view_b, ¶ms, &mut buffers);
483 let n_b = chess.detect_corners(&resp_b, ¶ms, 0).len();
484 assert!(n_b > 0);
485 }
486}