Skip to main content

chess_corners/
upscale.rs

1//! Optional pre-pipeline image upscaling.
2//!
3//! Low-resolution inputs — typical of small ChArUco crops — leave
4//! target corners inside the ChESS ring margin (5 px for the canonical
5//! detector), where the response is zeroed out and corners are lost.
6//! This module adds a first-class integer upscaling stage that runs
7//! ahead of the pyramid. Output corner coordinates are always rescaled
8//! back to input-image pixel coordinates by the facade, so callers do
9//! not need to be aware of the stage.
10//!
11//! Supported factors: 2, 3, 4 (bilinear only).
12
13use chess_corners_core::{CornerDescriptor, ImageView};
14use serde::{Deserialize, Serialize};
15
16/// Optional pre-pipeline integer-factor upscaling.
17///
18/// JSON shape mirrors the other enum-with-payload knobs (`Threshold`,
19/// `MultiscaleConfig`):
20///
21/// - `{ "disabled": null }` — no upscaling (default).
22/// - `{ "fixed": 2 }` — upscale by an integer factor before detection.
23///   Allowed factors: `{2, 3, 4}`. Output corner coordinates are
24///   rescaled back to the original input-pixel frame by the facade.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27#[non_exhaustive]
28pub enum UpscaleConfig {
29    /// Do not upscale.
30    #[default]
31    Disabled,
32    /// Upscale by a fixed integer factor (allowed: 2, 3, 4).
33    Fixed(u32),
34}
35
36impl UpscaleConfig {
37    /// Construct a disabled configuration (no upscaling).
38    pub fn disabled() -> Self {
39        Self::Disabled
40    }
41
42    /// Construct a fixed-factor configuration. Does not validate;
43    /// callers should run [`Self::validate`] before constructing a
44    /// [`crate::Detector`] (the constructor does this automatically).
45    pub fn fixed(factor: u32) -> Self {
46        Self::Fixed(factor)
47    }
48
49    /// Return the effective integer factor, or 1 when disabled.
50    #[inline]
51    pub fn effective_factor(&self) -> u32 {
52        match *self {
53            Self::Disabled => 1,
54            Self::Fixed(k) => k,
55        }
56    }
57
58    /// Validate that the configuration is well-formed.
59    pub fn validate(&self) -> Result<(), UpscaleError> {
60        match *self {
61            Self::Disabled => Ok(()),
62            Self::Fixed(2..=4) => Ok(()),
63            Self::Fixed(k) => Err(UpscaleError::InvalidFactor(k)),
64        }
65    }
66}
67
68/// Errors returned by upscaling setup or execution.
69#[derive(Debug, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum UpscaleError {
72    /// The requested factor is not in the supported set {2, 3, 4}.
73    InvalidFactor(u32),
74    /// Upscaled dimensions would overflow `usize`.
75    DimensionOverflow { src: (usize, usize), factor: u32 },
76    /// The image buffer length does not match the declared `src_w * src_h`.
77    DimensionMismatch {
78        /// Actual buffer length.
79        actual: usize,
80        /// Expected length (`src_w * src_h`).
81        expected: usize,
82    },
83}
84
85impl core::fmt::Display for UpscaleError {
86    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
87        match self {
88            Self::InvalidFactor(k) => {
89                write!(f, "upscale factor {k} not supported (expected 2, 3, or 4)")
90            }
91            Self::DimensionOverflow { src, factor } => write!(
92                f,
93                "upscaled dimensions overflow: {}x{} * {} exceeds usize",
94                src.0, src.1, factor
95            ),
96            Self::DimensionMismatch { actual, expected } => write!(
97                f,
98                "image buffer length mismatch: expected {expected} bytes (src_w*src_h), got {actual}"
99            ),
100        }
101    }
102}
103
104impl std::error::Error for UpscaleError {}
105
106/// Reusable scratch buffer for the upscaling stage.
107///
108/// Reuses its allocation across frames. The buffer grows on demand
109/// when dimensions change; it never shrinks, matching the
110/// `box-image-pyramid` buffer strategy.
111#[derive(Debug, Default, Clone)]
112pub struct UpscaleBuffers {
113    buf: Vec<u8>,
114    w: usize,
115    h: usize,
116}
117
118impl UpscaleBuffers {
119    /// Create an empty buffer. Allocation happens lazily on first use.
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    fn ensure(&mut self, w: usize, h: usize) {
125        self.w = w;
126        self.h = h;
127        let needed = w.saturating_mul(h);
128        if self.buf.len() < needed {
129            self.buf.resize(needed, 0);
130        }
131    }
132
133    /// Current width of the upscaled buffer (0 before first use).
134    pub fn width(&self) -> usize {
135        self.w
136    }
137
138    /// Current height of the upscaled buffer (0 before first use).
139    pub fn height(&self) -> usize {
140        self.h
141    }
142}
143
144/// Bilinear upscaling by an integer factor into the provided buffer.
145///
146/// Uses the half-pixel-center convention (consistent with OpenCV's
147/// `INTER_LINEAR` and `box-image-pyramid`'s downsampler).
148pub fn upscale_bilinear_u8<'a>(
149    src: &[u8],
150    src_w: usize,
151    src_h: usize,
152    factor: u32,
153    buffers: &'a mut UpscaleBuffers,
154) -> Result<ImageView<'a>, UpscaleError> {
155    if !matches!(factor, 2..=4) {
156        return Err(UpscaleError::InvalidFactor(factor));
157    }
158    let k = factor as usize;
159    let dst_w = src_w
160        .checked_mul(k)
161        .ok_or(UpscaleError::DimensionOverflow {
162            src: (src_w, src_h),
163            factor,
164        })?;
165    let dst_h = src_h
166        .checked_mul(k)
167        .ok_or(UpscaleError::DimensionOverflow {
168            src: (src_w, src_h),
169            factor,
170        })?;
171
172    let expected = src_w * src_h;
173    if src.len() != expected {
174        return Err(UpscaleError::DimensionMismatch {
175            actual: src.len(),
176            expected,
177        });
178    }
179    buffers.ensure(dst_w, dst_h);
180
181    if src_w == 0 || src_h == 0 {
182        return Ok(ImageView::from_u8_slice(dst_w, dst_h, &buffers.buf[..dst_w * dst_h]).unwrap());
183    }
184
185    let inv_k = 1.0f32 / factor as f32;
186    let max_x = src_w as i32 - 1;
187    let max_y = src_h as i32 - 1;
188
189    // Precompute per-column (x0, x1, wx). The pattern is periodic with
190    // period k, so we only need k entries; but for clarity we compute
191    // one per output column.
192    let mut xw: Vec<(usize, usize, f32)> = Vec::with_capacity(dst_w);
193    for x_out in 0..dst_w {
194        let xf = (x_out as f32 + 0.5) * inv_k - 0.5;
195        let x0 = xf.floor() as i32;
196        let wx = xf - x0 as f32;
197        let x0c = x0.clamp(0, max_x) as usize;
198        let x1c = (x0 + 1).clamp(0, max_x) as usize;
199        xw.push((x0c, x1c, wx));
200    }
201
202    for y_out in 0..dst_h {
203        let yf = (y_out as f32 + 0.5) * inv_k - 0.5;
204        let y0 = yf.floor() as i32;
205        let wy = yf - y0 as f32;
206        let y0c = y0.clamp(0, max_y) as usize;
207        let y1c = (y0 + 1).clamp(0, max_y) as usize;
208        let row0 = y0c * src_w;
209        let row1 = y1c * src_w;
210        let dst_row = y_out * dst_w;
211
212        for (x_out, &(x0, x1, wx)) in xw.iter().enumerate().take(dst_w) {
213            let i00 = src[row0 + x0] as f32;
214            let i10 = src[row0 + x1] as f32;
215            let i01 = src[row1 + x0] as f32;
216            let i11 = src[row1 + x1] as f32;
217            let top = i00 + (i10 - i00) * wx;
218            let bot = i01 + (i11 - i01) * wx;
219            let v = top + (bot - top) * wy;
220            // Round-half-away-from-zero then clamp to u8.
221            let rounded = v + 0.5;
222            buffers.buf[dst_row + x_out] = rounded.clamp(0.0, 255.0) as u8;
223        }
224    }
225
226    let slice = &buffers.buf[..dst_w * dst_h];
227    Ok(ImageView::from_u8_slice(dst_w, dst_h, slice).expect("dims match"))
228}
229
230/// Rescale corner positions from an upscaled image back to the
231/// original input-image pixel frame.
232///
233/// Uses the inverse of the forward half-pixel-center mapping from
234/// [`upscale_bilinear_u8`]:
235///
236/// ```text
237/// forward : x_out = (x_src + 0.5) * k - 0.5
238/// inverse : x_src = (x_out + 0.5) / k - 0.5
239///         = x_out / k - (k - 1) / (2k)
240/// ```
241///
242/// A naive `x /= k` biases returned coordinates by `(k − 1) / (2k)`
243/// pixels (+0.25 px at k = 2). Axis angles and sigmas are
244/// scale-invariant and are left untouched.
245pub fn rescale_descriptors_to_input(descriptors: &mut [CornerDescriptor], factor: u32) {
246    if factor <= 1 {
247        return;
248    }
249    let inv = 1.0f32 / factor as f32;
250    let shift = 0.5 * (1.0 - inv);
251    for d in descriptors.iter_mut() {
252        d.x = d.x * inv - shift;
253        d.y = d.y * inv - shift;
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn config_default_is_disabled() {
263        let cfg = UpscaleConfig::default();
264        assert_eq!(cfg, UpscaleConfig::Disabled);
265        assert_eq!(cfg.effective_factor(), 1);
266        assert!(cfg.validate().is_ok());
267    }
268
269    #[test]
270    fn config_rejects_invalid_factors() {
271        for bad in [0u32, 1, 5, 8] {
272            let cfg = UpscaleConfig::fixed(bad);
273            assert_eq!(cfg.validate(), Err(UpscaleError::InvalidFactor(bad)));
274        }
275    }
276
277    #[test]
278    fn config_accepts_valid_factors() {
279        for good in [2u32, 3, 4] {
280            let cfg = UpscaleConfig::fixed(good);
281            assert!(cfg.validate().is_ok());
282            assert_eq!(cfg.effective_factor(), good);
283        }
284    }
285
286    #[test]
287    fn disabled_round_trips_through_serde() {
288        let cfg = UpscaleConfig::Disabled;
289        let json = serde_json::to_string(&cfg).expect("serialize disabled");
290        assert!(json.contains("disabled"));
291        let decoded: UpscaleConfig = serde_json::from_str(&json).expect("deserialize disabled");
292        assert_eq!(decoded, cfg);
293    }
294
295    #[test]
296    fn fixed_round_trips_through_serde() {
297        let cfg = UpscaleConfig::Fixed(3);
298        let json = serde_json::to_string(&cfg).expect("serialize fixed");
299        assert!(json.contains("fixed"));
300        let decoded: UpscaleConfig = serde_json::from_str(&json).expect("deserialize fixed");
301        assert_eq!(decoded, cfg);
302    }
303
304    #[test]
305    fn upscale_factor_2_uniform_image_is_uniform() {
306        let src = vec![42u8; 8 * 6];
307        let mut buffers = UpscaleBuffers::new();
308        let view = upscale_bilinear_u8(&src, 8, 6, 2, &mut buffers).unwrap();
309        assert_eq!(view.width, 16);
310        assert_eq!(view.height, 12);
311        assert!(view.data.iter().all(|&v| v == 42));
312    }
313
314    #[test]
315    fn upscale_factor_2_of_1x1_fills_buffer() {
316        let src = [77u8];
317        let mut buffers = UpscaleBuffers::new();
318        let view = upscale_bilinear_u8(&src, 1, 1, 2, &mut buffers).unwrap();
319        assert_eq!(view.width, 2);
320        assert_eq!(view.height, 2);
321        assert!(view.data.iter().all(|&v| v == 77));
322    }
323
324    #[test]
325    fn upscale_preserves_linear_gradient_factor_2() {
326        // Horizontal ramp: src[i] = i * 10 for i in 0..8.
327        let src: Vec<u8> = (0..8).map(|i| i * 10).collect();
328        let src = {
329            let mut row = Vec::with_capacity(8 * 3);
330            for _ in 0..3 {
331                row.extend_from_slice(&src);
332            }
333            row
334        };
335        let mut buffers = UpscaleBuffers::new();
336        let view = upscale_bilinear_u8(&src, 8, 3, 2, &mut buffers).unwrap();
337        // The upscaled image should stay monotonic along each row.
338        for r in 0..view.height {
339            let row = &view.data[r * view.width..(r + 1) * view.width];
340            for w in row.windows(2) {
341                assert!(w[1] >= w[0].saturating_sub(1), "non-monotonic row: {row:?}");
342            }
343        }
344    }
345
346    #[test]
347    fn upscale_factor_3_doubles_dimensions_correctly() {
348        let src = vec![128u8; 5 * 4];
349        let mut buffers = UpscaleBuffers::new();
350        let view = upscale_bilinear_u8(&src, 5, 4, 3, &mut buffers).unwrap();
351        assert_eq!(view.width, 15);
352        assert_eq!(view.height, 12);
353        assert_eq!(view.data.len(), 180);
354    }
355
356    #[test]
357    fn buffers_are_reused_across_calls() {
358        let src1 = vec![10u8; 4 * 4];
359        let src2 = vec![200u8; 4 * 4];
360        let mut buffers = UpscaleBuffers::new();
361        let _ = upscale_bilinear_u8(&src1, 4, 4, 2, &mut buffers).unwrap();
362        let cap1 = buffers.buf.capacity();
363        let _ = upscale_bilinear_u8(&src2, 4, 4, 2, &mut buffers).unwrap();
364        assert_eq!(buffers.buf.capacity(), cap1, "buffer should be reused");
365    }
366
367    #[test]
368    fn rejects_invalid_factor_at_runtime() {
369        let src = vec![0u8; 4];
370        let mut buffers = UpscaleBuffers::new();
371        let err = upscale_bilinear_u8(&src, 2, 2, 5, &mut buffers).unwrap_err();
372        assert_eq!(err, UpscaleError::InvalidFactor(5));
373    }
374
375    #[test]
376    fn rescale_inverts_half_pixel_upscale() {
377        use chess_corners_core::{AxisEstimate, CornerDescriptor};
378
379        // Forward mapping in `upscale_bilinear_u8`:
380        //   x_out = (x_src + 0.5) * k - 0.5
381        // For a corner at source position (7.25, 3.0) and factor k = 2,
382        // the upscaled detection should land at (14.5, 6.5). Running
383        // that through `rescale_descriptors_to_input` must return
384        // exactly the original source position, not x_out / k.
385        fn desc(x: f32, y: f32) -> CornerDescriptor {
386            CornerDescriptor::new(
387                x,
388                y,
389                1.0,
390                0.0,
391                0.0,
392                [AxisEstimate::new(0.0, 0.0), AxisEstimate::new(0.0, 0.0)],
393            )
394        }
395
396        for &(k, x_src, y_src) in &[
397            (2u32, 7.25f32, 3.0f32),
398            (3u32, 4.0f32, 8.5f32),
399            (4u32, 0.5f32, 12.25f32),
400        ] {
401            let kf = k as f32;
402            let x_out = (x_src + 0.5) * kf - 0.5;
403            let y_out = (y_src + 0.5) * kf - 0.5;
404
405            let mut d = [desc(x_out, y_out)];
406            rescale_descriptors_to_input(&mut d, k);
407            assert!(
408                (d[0].x - x_src).abs() < 1e-5,
409                "k={k}: x {} != expected {x_src}",
410                d[0].x
411            );
412            assert!(
413                (d[0].y - y_src).abs() < 1e-5,
414                "k={k}: y {} != expected {y_src}",
415                d[0].y
416            );
417        }
418    }
419
420    #[test]
421    fn rescale_is_noop_for_factor_1() {
422        use chess_corners_core::{AxisEstimate, CornerDescriptor};
423        let mut d = [CornerDescriptor::new(
424            2.5,
425            3.75,
426            1.0,
427            0.0,
428            0.0,
429            [AxisEstimate::new(0.0, 0.0), AxisEstimate::new(0.0, 0.0)],
430        )];
431        rescale_descriptors_to_input(&mut d, 1);
432        assert_eq!(d[0].x, 2.5);
433        assert_eq!(d[0].y, 3.75);
434    }
435}