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 in v1).
12
13use chess_corners_core::{CornerDescriptor, ImageView};
14use serde::{Deserialize, Serialize};
15
16/// Upscaling mode, encoded into JSON as `"disabled"` or `"fixed"`.
17///
18/// Kept as a separate enum for forward compatibility — future modes
19/// (auto-fit, non-integer factors) can be added without rewriting
20/// callers.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum UpscaleMode {
25    /// Do not upscale (default).
26    #[default]
27    Disabled,
28    /// Upscale by a fixed integer factor (allowed: 2, 3, 4).
29    Fixed,
30}
31
32/// Upscaling configuration exposed through [`crate::ChessConfig`].
33///
34/// JSON shape: `{ "mode": "disabled" }` or `{ "mode": "fixed", "factor": 2 }`.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(default)]
37#[non_exhaustive]
38pub struct UpscaleConfig {
39    /// Selected upscale mode. Default: [`UpscaleMode::Disabled`].
40    pub mode: UpscaleMode,
41    /// Integer factor used when `mode == Fixed`. Ignored otherwise.
42    /// Must be 2, 3, or 4.
43    pub factor: u32,
44}
45
46impl Default for UpscaleConfig {
47    fn default() -> Self {
48        Self {
49            mode: UpscaleMode::Disabled,
50            factor: 2,
51        }
52    }
53}
54
55impl UpscaleConfig {
56    /// Construct a disabled configuration (no upscaling).
57    pub fn disabled() -> Self {
58        Self::default()
59    }
60
61    /// Construct a fixed-factor configuration. Does not validate.
62    pub fn fixed(factor: u32) -> Self {
63        Self {
64            mode: UpscaleMode::Fixed,
65            factor,
66        }
67    }
68
69    /// Return the effective integer factor, or 1 when disabled.
70    #[inline]
71    pub fn effective_factor(&self) -> u32 {
72        match self.mode {
73            UpscaleMode::Disabled => 1,
74            UpscaleMode::Fixed => self.factor,
75        }
76    }
77
78    /// Validate that the configuration is well-formed.
79    pub fn validate(&self) -> Result<(), UpscaleError> {
80        if matches!(self.mode, UpscaleMode::Fixed) && !matches!(self.factor, 2..=4) {
81            return Err(UpscaleError::InvalidFactor(self.factor));
82        }
83        Ok(())
84    }
85}
86
87/// Errors returned by upscaling setup or execution.
88#[derive(Debug, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum UpscaleError {
91    /// The requested factor is not in the supported set {2, 3, 4}.
92    InvalidFactor(u32),
93    /// Upscaled dimensions would overflow `usize`.
94    DimensionOverflow { src: (usize, usize), factor: u32 },
95    /// The image buffer length does not match the declared `src_w * src_h`.
96    DimensionMismatch {
97        /// Actual buffer length.
98        actual: usize,
99        /// Expected length (`src_w * src_h`).
100        expected: usize,
101    },
102}
103
104impl core::fmt::Display for UpscaleError {
105    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
106        match self {
107            Self::InvalidFactor(k) => {
108                write!(f, "upscale factor {k} not supported (expected 2, 3, or 4)")
109            }
110            Self::DimensionOverflow { src, factor } => write!(
111                f,
112                "upscaled dimensions overflow: {}x{} * {} exceeds usize",
113                src.0, src.1, factor
114            ),
115            Self::DimensionMismatch { actual, expected } => write!(
116                f,
117                "image buffer length mismatch: expected {expected} bytes (src_w*src_h), got {actual}"
118            ),
119        }
120    }
121}
122
123impl std::error::Error for UpscaleError {}
124
125/// Reusable scratch buffer for the upscaling stage.
126///
127/// Reuses its allocation across frames. The buffer grows on demand
128/// when dimensions change; it never shrinks, matching the
129/// `box-image-pyramid` buffer strategy.
130#[derive(Debug, Default, Clone)]
131pub struct UpscaleBuffers {
132    buf: Vec<u8>,
133    w: usize,
134    h: usize,
135}
136
137impl UpscaleBuffers {
138    /// Create an empty buffer. Allocation happens lazily on first use.
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    fn ensure(&mut self, w: usize, h: usize) {
144        self.w = w;
145        self.h = h;
146        let needed = w.saturating_mul(h);
147        if self.buf.len() < needed {
148            self.buf.resize(needed, 0);
149        }
150    }
151
152    /// Current width of the upscaled buffer (0 before first use).
153    pub fn width(&self) -> usize {
154        self.w
155    }
156
157    /// Current height of the upscaled buffer (0 before first use).
158    pub fn height(&self) -> usize {
159        self.h
160    }
161}
162
163/// Bilinear upscaling by an integer factor into the provided buffer.
164///
165/// Uses the half-pixel-center convention (consistent with OpenCV's
166/// `INTER_LINEAR` and `box-image-pyramid`'s downsampler).
167pub fn upscale_bilinear_u8<'a>(
168    src: &[u8],
169    src_w: usize,
170    src_h: usize,
171    factor: u32,
172    buffers: &'a mut UpscaleBuffers,
173) -> Result<ImageView<'a>, UpscaleError> {
174    if !matches!(factor, 2..=4) {
175        return Err(UpscaleError::InvalidFactor(factor));
176    }
177    let k = factor as usize;
178    let dst_w = src_w
179        .checked_mul(k)
180        .ok_or(UpscaleError::DimensionOverflow {
181            src: (src_w, src_h),
182            factor,
183        })?;
184    let dst_h = src_h
185        .checked_mul(k)
186        .ok_or(UpscaleError::DimensionOverflow {
187            src: (src_w, src_h),
188            factor,
189        })?;
190
191    let expected = src_w * src_h;
192    if src.len() != expected {
193        return Err(UpscaleError::DimensionMismatch {
194            actual: src.len(),
195            expected,
196        });
197    }
198    buffers.ensure(dst_w, dst_h);
199
200    if src_w == 0 || src_h == 0 {
201        return Ok(ImageView::from_u8_slice(dst_w, dst_h, &buffers.buf[..dst_w * dst_h]).unwrap());
202    }
203
204    let inv_k = 1.0f32 / factor as f32;
205    let max_x = src_w as i32 - 1;
206    let max_y = src_h as i32 - 1;
207
208    // Precompute per-column (x0, x1, wx). The pattern is periodic with
209    // period k, so we only need k entries; but for clarity we compute
210    // one per output column.
211    let mut xw: Vec<(usize, usize, f32)> = Vec::with_capacity(dst_w);
212    for x_out in 0..dst_w {
213        let xf = (x_out as f32 + 0.5) * inv_k - 0.5;
214        let x0 = xf.floor() as i32;
215        let wx = xf - x0 as f32;
216        let x0c = x0.clamp(0, max_x) as usize;
217        let x1c = (x0 + 1).clamp(0, max_x) as usize;
218        xw.push((x0c, x1c, wx));
219    }
220
221    for y_out in 0..dst_h {
222        let yf = (y_out as f32 + 0.5) * inv_k - 0.5;
223        let y0 = yf.floor() as i32;
224        let wy = yf - y0 as f32;
225        let y0c = y0.clamp(0, max_y) as usize;
226        let y1c = (y0 + 1).clamp(0, max_y) as usize;
227        let row0 = y0c * src_w;
228        let row1 = y1c * src_w;
229        let dst_row = y_out * dst_w;
230
231        for (x_out, &(x0, x1, wx)) in xw.iter().enumerate().take(dst_w) {
232            let i00 = src[row0 + x0] as f32;
233            let i10 = src[row0 + x1] as f32;
234            let i01 = src[row1 + x0] as f32;
235            let i11 = src[row1 + x1] as f32;
236            let top = i00 + (i10 - i00) * wx;
237            let bot = i01 + (i11 - i01) * wx;
238            let v = top + (bot - top) * wy;
239            // Round-half-away-from-zero then clamp to u8.
240            let rounded = v + 0.5;
241            buffers.buf[dst_row + x_out] = rounded.clamp(0.0, 255.0) as u8;
242        }
243    }
244
245    let slice = &buffers.buf[..dst_w * dst_h];
246    Ok(ImageView::from_u8_slice(dst_w, dst_h, slice).expect("dims match"))
247}
248
249/// Rescale corner positions from an upscaled image back to the
250/// original input-image pixel frame.
251///
252/// Uses the inverse of the forward half-pixel-center mapping from
253/// [`upscale_bilinear_u8`]:
254///
255/// ```text
256/// forward : x_out = (x_src + 0.5) · k − 0.5
257/// inverse : x_src = (x_out + 0.5) / k − 0.5
258///         = x_out / k − (k − 1) / (2k)
259/// ```
260///
261/// A naive `x /= k` biases returned coordinates by `(k − 1) / (2k)`
262/// pixels (+0.25 px at k = 2). Axis angles and sigmas are
263/// scale-invariant and are left untouched.
264pub fn rescale_descriptors_to_input(descriptors: &mut [CornerDescriptor], factor: u32) {
265    if factor <= 1 {
266        return;
267    }
268    let inv = 1.0f32 / factor as f32;
269    let shift = 0.5 * (1.0 - inv);
270    for d in descriptors.iter_mut() {
271        d.x = d.x * inv - shift;
272        d.y = d.y * inv - shift;
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn config_default_is_disabled() {
282        let cfg = UpscaleConfig::default();
283        assert_eq!(cfg.effective_factor(), 1);
284        assert!(cfg.validate().is_ok());
285    }
286
287    #[test]
288    fn config_rejects_invalid_factors() {
289        for bad in [0u32, 1, 5, 8] {
290            let cfg = UpscaleConfig::fixed(bad);
291            assert_eq!(cfg.validate(), Err(UpscaleError::InvalidFactor(bad)));
292        }
293    }
294
295    #[test]
296    fn config_accepts_valid_factors() {
297        for good in [2u32, 3, 4] {
298            let cfg = UpscaleConfig::fixed(good);
299            assert!(cfg.validate().is_ok());
300            assert_eq!(cfg.effective_factor(), good);
301        }
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}