1use chess_corners_core::{CornerDescriptor, ImageView};
14use serde::{Deserialize, Serialize};
15
16#[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 #[default]
32 Disabled,
33 Fixed(#[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 4)))] u32),
35}
36
37impl UpscaleConfig {
38 pub fn disabled() -> Self {
40 Self::Disabled
41 }
42
43 pub fn fixed(factor: u32) -> Self {
47 Self::Fixed(factor)
48 }
49
50 #[inline]
52 pub fn effective_factor(&self) -> u32 {
53 match *self {
54 Self::Disabled => 1,
55 Self::Fixed(k) => k,
56 }
57 }
58
59 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#[derive(Debug, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum UpscaleError {
73 InvalidFactor(u32),
75 DimensionOverflow {
77 src: (usize, usize),
79 factor: u32,
81 },
82 DimensionMismatch {
84 actual: usize,
86 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#[derive(Debug, Default, Clone)]
118pub struct UpscaleBuffers {
119 buf: Vec<u8>,
120 w: usize,
121 h: usize,
122}
123
124impl UpscaleBuffers {
125 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 pub fn width(&self) -> usize {
141 self.w
142 }
143
144 pub fn height(&self) -> usize {
146 self.h
147 }
148}
149
150pub 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 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 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
239pub 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]; 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 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 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 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}