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