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