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, ¶ms, &mut buffers);
465 let n_a = chess.detect_corners(&resp_a, ¶ms, 0).len();
466 assert!(n_a > 0);
467
468 let resp_b = chess.compute_response(view_b, ¶ms, &mut buffers);
469 let n_b = chess.detect_corners(&resp_b, ¶ms, 0).len();
470 assert!(n_b > 0);
471 }
472}