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`](chess_corners_core); 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.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.radon_detector_params();
63    let mut rb = RadonBuffers::new();
64
65    if factor <= 1 {
66        let resp = radon_response_u8(
67            view.data(),
68            view.width(),
69            view.height(),
70            &radon_params,
71            &mut rb,
72        );
73        return Ok(resp.to_response_map());
74    }
75
76    let mut up_buffers = UpscaleBuffers::new();
77    let upscaled = upscale::upscale_bilinear_u8(img, src_w, src_h, factor, &mut up_buffers)?;
78    let resp = radon_response_u8(
79        upscaled.data(),
80        upscaled.width(),
81        upscaled.height(),
82        &radon_params,
83        &mut rb,
84    );
85    Ok(resp.to_response_map())
86}
87
88/// Compute the Radon response heatmap from an `image::GrayImage`.
89///
90/// Convenience wrapper over [`radon_heatmap_u8`] when the `image`
91/// feature is enabled.
92///
93/// # Errors
94///
95/// Returns [`ChessError::Upscale`] if the upscale configuration in `cfg` is invalid.
96/// A dimension mismatch is not possible for `GrayImage` since the `image` crate
97/// guarantees `as_raw().len() == width * height`.
98#[cfg(feature = "image")]
99pub fn radon_heatmap_image(
100    img: &::image::GrayImage,
101    cfg: &DetectorConfig,
102) -> Result<ResponseMap, ChessError> {
103    let (w, h) = img.dimensions();
104    radon_heatmap_u8(img.as_raw(), w, h, cfg)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::DetectorConfig;
111    use chess_corners_core::{radon_response_u8 as core_radon, RadonBuffers as CoreRadonBuffers};
112
113    fn synthetic_board(w: usize, h: usize) -> Vec<u8> {
114        // 8×8 alternating-square board scaled to (w, h). Generates real
115        // saddle structure so the Radon response is not all zeros.
116        let cell = (w.min(h) / 9).max(2);
117        let mut out = vec![0u8; w * h];
118        for y in 0..h {
119            for x in 0..w {
120                let cx = x / cell;
121                let cy = y / cell;
122                out[y * w + x] = if (cx + cy) & 1 == 0 { 220 } else { 35 };
123            }
124        }
125        out
126    }
127
128    #[test]
129    fn radon_heatmap_u8_reports_dimension_mismatch() {
130        let cfg = DetectorConfig::radon();
131        let img = vec![0u8; 10];
132        let err = radon_heatmap_u8(&img, 8, 8, &cfg).unwrap_err();
133        match err {
134            ChessError::DimensionMismatch { expected, actual } => {
135                assert_eq!(expected, 64);
136                assert_eq!(actual, 10);
137            }
138            other => panic!("expected ChessError::DimensionMismatch, got {other:?}"),
139        }
140    }
141
142    #[test]
143    fn heatmap_matches_core_path_no_upscale() {
144        let (w, h) = (96usize, 72usize);
145        let img = synthetic_board(w, h);
146        let cfg = DetectorConfig::radon();
147
148        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
149
150        let radon_params = cfg.radon_detector_params();
151        let mut rb = CoreRadonBuffers::new();
152        let view = core_radon(&img, w, h, &radon_params, &mut rb);
153        assert_eq!(map.width(), view.width());
154        assert_eq!(map.height(), view.height());
155        assert_eq!(map.data().len(), view.data().len());
156        // Bitwise-identical: the facade just copies the borrowed slice.
157        assert_eq!(map.data(), view.data());
158    }
159
160    #[test]
161    fn heatmap_dimensions_match_working_resolution() {
162        let (w, h) = (96usize, 72usize);
163        let img = synthetic_board(w, h);
164        let cfg = DetectorConfig::radon();
165        let upsample = cfg.radon_detector_params().image_upsample.clamp(1, 2) as usize;
166
167        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
168        assert_eq!(map.width(), w * upsample);
169        assert_eq!(map.height(), h * upsample);
170    }
171
172    #[test]
173    fn heatmap_is_non_zero_on_a_board() {
174        let (w, h) = (96usize, 72usize);
175        let img = synthetic_board(w, h);
176        let cfg = DetectorConfig::radon();
177
178        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
179        let max = map.data().iter().copied().fold(f32::NEG_INFINITY, f32::max);
180        assert!(max > 0.0, "expected positive Radon response on a board");
181    }
182
183    #[test]
184    fn heatmap_honors_upscale_factor() {
185        use crate::upscale::UpscaleConfig;
186
187        let (w, h) = (48usize, 36usize);
188        let img = synthetic_board(w, h);
189        let mut cfg = DetectorConfig::radon();
190        cfg.upscale = UpscaleConfig::fixed(2);
191        let radon_upsample = cfg.radon_detector_params().image_upsample.clamp(1, 2) as usize;
192
193        let map = radon_heatmap_u8(&img, w as u32, h as u32, &cfg).unwrap();
194        // Working resolution = input × upscale × radon_image_upsample.
195        assert_eq!(map.width(), w * 2 * radon_upsample);
196        assert_eq!(map.height(), h * 2 * radon_upsample);
197    }
198}