Skip to main content

chess_corners/
radon.rs

1//! Public Radon-detector convenience functions.
2//!
3//! The whole-image Duda-Frese Radon detector lives in
4//! [`chess_corners_core::detect::radon`]; the corner-detection path is
5//! exposed via [`crate::Detector`] when the active
6//! [`DetectorConfig::strategy`](crate::DetectorConfig::strategy) is
7//! [`DetectionStrategy::Radon`](crate::DetectionStrategy::Radon). This module
8//! adds a thin wrapper that returns the dense Radon response heatmap
9//! (the intermediate `(max_α S_α − min_α S_α)²` image) for
10//! visualization and debugging.
11//!
12//! The heatmap is returned at *working resolution* — that is,
13//! `width * upscale_factor * radon_image_upsample` by the same in `y`.
14//! Use [`ResponseMap::width`] / [`ResponseMap::height`] for the actual
15//! dimensions; the working-to-input scale factor is
16//! `cfg.upscale.effective_factor() *
17//! cfg.to_radon_detector_params().image_upsample.clamp(1, 2)` (the
18//! Radon-side factor lives in the [`RadonConfig`](crate::RadonConfig)
19//! payload of [`DetectionStrategy::Radon`](crate::DetectionStrategy)).
20
21use chess_corners_core::{radon_response_u8, ImageView, RadonBuffers, ResponseMap};
22
23use crate::config::DetectorConfig;
24use crate::error::ChessError;
25use crate::upscale::{self, UpscaleBuffers};
26
27/// Compute the whole-image Radon response heatmap from a raw
28/// grayscale buffer.
29///
30/// `img` must be `width * height` bytes in row-major order. If
31/// `cfg.upscale` is enabled, the input is upscaled first (same path as
32/// [`crate::Detector`]) and the heatmap is returned at the
33/// working resolution of the upscaled + radon-supersampled image.
34///
35/// The heatmap data is row-major `f32`, length
36/// `map.width() * map.height()`. Values are non-negative.
37///
38/// # Errors
39///
40/// Returns [`ChessError::DimensionMismatch`] if `img.len() != width * height`.
41/// Returns [`ChessError::Upscale`] if the upscale configuration is invalid.
42pub fn radon_heatmap_u8(
43    img: &[u8],
44    width: u32,
45    height: u32,
46    cfg: &DetectorConfig,
47) -> Result<ResponseMap, ChessError> {
48    cfg.upscale.validate()?;
49
50    let src_w = width as usize;
51    let src_h = height as usize;
52    let expected = src_w * src_h;
53    if img.len() != expected {
54        return Err(ChessError::DimensionMismatch {
55            expected,
56            actual: img.len(),
57        });
58    }
59    let view = ImageView::from_u8_slice(src_w, src_h, img).expect("dimensions were checked above");
60
61    let factor = cfg.upscale.effective_factor();
62    let radon_params = cfg.to_radon_detector_params();
63    let mut rb = RadonBuffers::new();
64
65    if factor <= 1 {
66        let resp = radon_response_u8(view.data, view.width, view.height, &radon_params, &mut rb);
67        return Ok(resp.to_response_map());
68    }
69
70    let mut up_buffers = UpscaleBuffers::new();
71    let upscaled = upscale::upscale_bilinear_u8(img, src_w, src_h, factor, &mut up_buffers)?;
72    let resp = radon_response_u8(
73        upscaled.data,
74        upscaled.width,
75        upscaled.height,
76        &radon_params,
77        &mut rb,
78    );
79    Ok(resp.to_response_map())
80}
81
82/// Compute the Radon response heatmap from an `image::GrayImage`.
83///
84/// Convenience wrapper over [`radon_heatmap_u8`] when the `image`
85/// feature is enabled.
86///
87/// # Errors
88///
89/// Returns [`ChessError::Upscale`] if the upscale configuration in `cfg` is invalid.
90/// A dimension mismatch is not possible for `GrayImage` since the `image` crate
91/// guarantees `as_raw().len() == width * height`.
92#[cfg(feature = "image")]
93pub fn radon_heatmap_image(
94    img: &::image::GrayImage,
95    cfg: &DetectorConfig,
96) -> Result<ResponseMap, ChessError> {
97    let (w, h) = img.dimensions();
98    radon_heatmap_u8(img.as_raw(), w, h, cfg)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::DetectorConfig;
105    use chess_corners_core::{radon_response_u8 as core_radon, RadonBuffers as CoreRadonBuffers};
106
107    fn synthetic_board(w: usize, h: usize) -> Vec<u8> {
108        // 8×8 alternating-square board scaled to (w, h). Generates real
109        // saddle structure so the Radon response is not all zeros.
110        let cell = (w.min(h) / 9).max(2);
111        let mut out = vec![0u8; w * h];
112        for y in 0..h {
113            for x in 0..w {
114                let cx = x / cell;
115                let cy = y / cell;
116                out[y * w + x] = if (cx + cy) & 1 == 0 { 220 } else { 35 };
117            }
118        }
119        out
120    }
121
122    #[test]
123    fn heatmap_matches_core_path_no_upscale() {
124        let (w, h) = (96usize, 72usize);
125        let img = synthetic_board(w, h);
126        let cfg = DetectorConfig::radon();
127
128        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
129
130        let radon_params = cfg.to_radon_detector_params();
131        let mut rb = CoreRadonBuffers::new();
132        let view = core_radon(&img, w, h, &radon_params, &mut rb);
133        assert_eq!(map.width(), view.width());
134        assert_eq!(map.height(), view.height());
135        assert_eq!(map.data().len(), view.data().len());
136        // Bitwise-identical: the facade just copies the borrowed slice.
137        assert_eq!(map.data(), view.data());
138    }
139
140    #[test]
141    fn heatmap_dimensions_match_working_resolution() {
142        let (w, h) = (96usize, 72usize);
143        let img = synthetic_board(w, h);
144        let cfg = DetectorConfig::radon();
145        let upsample = cfg.to_radon_detector_params().image_upsample.clamp(1, 2) as usize;
146
147        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
148        assert_eq!(map.width(), w * upsample);
149        assert_eq!(map.height(), h * upsample);
150    }
151
152    #[test]
153    fn heatmap_is_non_zero_on_a_board() {
154        let (w, h) = (96usize, 72usize);
155        let img = synthetic_board(w, h);
156        let cfg = DetectorConfig::radon();
157
158        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
159        let max = map.data().iter().copied().fold(f32::NEG_INFINITY, f32::max);
160        assert!(max > 0.0, "expected positive Radon response on a board");
161    }
162
163    #[test]
164    fn heatmap_honors_upscale_factor() {
165        use crate::upscale::UpscaleConfig;
166
167        let (w, h) = (48usize, 36usize);
168        let img = synthetic_board(w, h);
169        let mut cfg = DetectorConfig::radon();
170        cfg.upscale = UpscaleConfig::fixed(2);
171        let radon_upsample = cfg.to_radon_detector_params().image_upsample.clamp(1, 2) as usize;
172
173        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
174        // Working resolution = input × upscale × radon_image_upsample.
175        assert_eq!(map.width(), w * 2 * radon_upsample);
176        assert_eq!(map.height(), h * 2 * radon_upsample);
177    }
178}