chess_corners/diagnostics.rs
1//! Opt-in diagnostic channel for inspecting how a detection was produced.
2//!
3//! The items in this module expose **intermediate evidence** from the
4//! detector pipeline — the dense ChESS response map and the Radon
5//! heatmap — rather than the detection result itself. They exist for
6//! debugging, parameter tuning, and visualization: rendering a response
7//! map as an overlay, checking why a corner was or was not seeded, or
8//! sweeping a threshold against a heatmap.
9//!
10//! These are **not** part of the normal detection result contract. The
11//! supported way to obtain corners is [`Detector`] plus
12//! [`DetectorConfig`](crate::DetectorConfig); a typical consumer never
13//! needs this module.
14//!
15//! # Stability
16//!
17//! This channel carries a **looser stability promise** than
18//! [`Detector`] and [`DetectorConfig`](crate::DetectorConfig).
19//! The shape of an intermediate response map or heatmap may change as
20//! the detector internals evolve, even when the detection result
21//! contract does not. Treat anything reachable here as advisory
22//! diagnostic data, not a versioned API guarantee.
23
24/// Storage for a dense detector response map (one score per pixel).
25pub use chess_corners_core::ResponseMap;
26
27/// Compute the dense ChESS response map for an 8-bit grayscale image.
28pub use chess_corners_core::chess_response_u8;
29
30/// Compute the Radon corner heatmap for an 8-bit grayscale buffer.
31pub use crate::radon::radon_heatmap_u8;
32
33/// Compute the Radon corner heatmap from an `image::GrayImage`.
34#[cfg(feature = "image")]
35pub use crate::radon::radon_heatmap_image;
36
37use crate::error::ChessError;
38use crate::Detector;
39
40/// Detector-bound accessor for the diagnostic outputs in this module.
41///
42/// This is the detector-bound half of the diagnostics channel: it
43/// exposes the same intermediate response maps and heatmaps as the
44/// free functions above, but sources its parameters from an existing,
45/// already-configured [`Detector`] instead of asking the caller to
46/// re-supply a [`DetectorConfig`](crate::DetectorConfig). It is the
47/// convenient path when you already hold a configured [`Detector`].
48///
49/// Obtain one with [`Detector::diagnostics`]. The handle borrows the
50/// detector; it neither clones the configuration nor mutates the
51/// detector.
52///
53/// # Stability
54///
55/// Like the free functions in this module, the methods here carry a
56/// **looser stability promise** than [`Detector::detect`] and the
57/// detection result contract. Treat their output as advisory
58/// diagnostic data, not a versioned API guarantee.
59pub struct DetectorDiagnostics<'a> {
60 detector: &'a Detector,
61}
62
63impl<'a> DetectorDiagnostics<'a> {
64 /// Wrap a borrowed detector. Constructed via [`Detector::diagnostics`].
65 pub(crate) fn new(detector: &'a Detector) -> Self {
66 Self { detector }
67 }
68
69 /// Compute the dense ChESS response map for an 8-bit grayscale
70 /// buffer, using the ring and threshold parameters of the bound
71 /// detector's [`DetectorConfig`](crate::DetectorConfig).
72 ///
73 /// `img` must be `width * height` bytes in row-major order. The
74 /// response is computed at input resolution (the detector's
75 /// upscale stage is not applied here); the returned
76 /// [`ResponseMap`] therefore has the same dimensions as the input.
77 ///
78 /// This mirrors the free function [`chess_response_u8`] but sources
79 /// [`ChessParams`](chess_corners_core::ChessParams) from the
80 /// bound detector.
81 ///
82 /// # Errors
83 ///
84 /// Returns [`ChessError::DimensionMismatch`] if
85 /// `img.len() != width * height`.
86 pub fn chess_response_u8(
87 &self,
88 img: &[u8],
89 width: u32,
90 height: u32,
91 ) -> Result<ResponseMap, ChessError> {
92 let w = width as usize;
93 let h = height as usize;
94 let expected = w * h;
95 if img.len() != expected {
96 return Err(ChessError::DimensionMismatch {
97 expected,
98 actual: img.len(),
99 });
100 }
101 let params = self.detector.config().chess_params();
102 Ok(chess_response_u8(img, w, h, ¶ms))
103 }
104
105 /// Compute the dense Radon corner heatmap for an 8-bit grayscale
106 /// buffer, using the bound detector's
107 /// [`DetectorConfig`](crate::DetectorConfig).
108 ///
109 /// `img` must be `width * height` bytes in row-major order. As with
110 /// the free function [`radon_heatmap_u8`], if the detector's
111 /// upscale configuration is enabled the input is upscaled first and
112 /// the heatmap is returned at working resolution.
113 ///
114 /// This mirrors [`radon_heatmap_u8`] but sources the
115 /// [`DetectorConfig`](crate::DetectorConfig) from the bound
116 /// detector.
117 ///
118 /// # Errors
119 ///
120 /// Returns [`ChessError::DimensionMismatch`] if
121 /// `img.len() != width * height`. Returns [`ChessError::Upscale`]
122 /// if the detector's upscale configuration is invalid.
123 pub fn radon_heatmap_u8(
124 &self,
125 img: &[u8],
126 width: u32,
127 height: u32,
128 ) -> Result<ResponseMap, ChessError> {
129 crate::radon::radon_heatmap_u8(img, width, height, self.detector.config())
130 }
131
132 /// Compute the dense Radon corner heatmap from an
133 /// [`image::GrayImage`], using the bound detector's
134 /// [`DetectorConfig`](crate::DetectorConfig). See
135 /// [`Self::radon_heatmap_u8`].
136 ///
137 /// # Errors
138 ///
139 /// Inherits the error contract of [`Self::radon_heatmap_u8`].
140 #[cfg(feature = "image")]
141 pub fn radon_heatmap(&self, img: &image::GrayImage) -> Result<ResponseMap, ChessError> {
142 self.radon_heatmap_u8(img.as_raw(), img.width(), img.height())
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::{Detector, DetectorConfig};
150 use chess_corners_testutil::aa_chessboard;
151
152 fn synthetic_board(size: usize) -> Vec<u8> {
153 aa_chessboard(size, 12, (0.0, 0.0), 20, 220)
154 }
155
156 #[test]
157 fn chess_response_map_is_plausibly_shaped_for_chess_strategy() {
158 let size = 96usize;
159 let img = synthetic_board(size);
160 let det = Detector::new(DetectorConfig::chess()).unwrap();
161
162 let map = det
163 .diagnostics()
164 .chess_response_u8(&img, size as u32, size as u32)
165 .unwrap();
166
167 assert_eq!(map.width(), size);
168 assert_eq!(map.height(), size);
169 assert_eq!(map.data().len(), size * size);
170 let max = map.data().iter().copied().fold(f32::NEG_INFINITY, f32::max);
171 assert!(
172 max > 0.0,
173 "expected a positive ChESS response somewhere on a synthetic board"
174 );
175 }
176
177 #[test]
178 fn radon_heatmap_is_plausibly_shaped_for_radon_strategy() {
179 let size = 96usize;
180 let img = synthetic_board(size);
181 let det = Detector::new(DetectorConfig::radon()).unwrap();
182
183 let map = det
184 .diagnostics()
185 .radon_heatmap_u8(&img, size as u32, size as u32)
186 .unwrap();
187
188 assert!(map.width() > 0 && map.height() > 0);
189 assert_eq!(map.data().len(), map.width() * map.height());
190 let max = map.data().iter().copied().fold(f32::NEG_INFINITY, f32::max);
191 assert!(
192 max > 0.0,
193 "expected a positive Radon response somewhere on a synthetic board"
194 );
195 }
196
197 #[test]
198 fn chess_response_u8_reports_dimension_mismatch() {
199 let det = Detector::with_default();
200 let img = vec![0u8; 10];
201 let err = det.diagnostics().chess_response_u8(&img, 8, 8).unwrap_err();
202 match err {
203 ChessError::DimensionMismatch { expected, actual } => {
204 assert_eq!(expected, 64);
205 assert_eq!(actual, 10);
206 }
207 other => panic!("expected ChessError::DimensionMismatch, got {other:?}"),
208 }
209 }
210}