Skip to main content

oximedia_gpu/
scale_kernel.rs

1//! GPU scaling kernels (CPU simulation via Rayon).
2//!
3//! Provides parallel image scaling operations that simulate GPU compute
4//! shader dispatch. Supports packed RGBA and planar YUV formats.
5//!
6//! # Supported filters
7//!
8//! | Filter | Quality | Speed |
9//! |--------|---------|-------|
10//! | [`ScaleFilter::Nearest`] | Low | Fastest |
11//! | [`ScaleFilter::Bilinear`] | Medium | Fast |
12//! | [`ScaleFilter::Bicubic`] | High | Medium |
13//! | [`ScaleFilter::Area`] | High (downscale) | Medium |
14//!
15//! # Example
16//!
17//! ```rust
18//! use oximedia_gpu::scale_kernel::{ScaleKernel, ScaleFilter};
19//!
20//! let src = vec![0u8; 8 * 8 * 4]; // 8×8 RGBA
21//! let mut dst = vec![0u8; 4 * 4 * 4]; // scale to 4×4
22//! ScaleKernel::scale_rgba(&src, 8, 8, &mut dst, 4, 4, ScaleFilter::Bilinear)
23//!     .expect("scale failed");
24//! ```
25
26use rayon::prelude::*;
27use thiserror::Error;
28
29// ─── Error ────────────────────────────────────────────────────────────────────
30
31/// Errors returned by scale kernel operations.
32#[derive(Debug, Clone, PartialEq, Error)]
33pub enum ScaleError {
34    /// Source or destination buffer has incorrect length.
35    #[error("Buffer size mismatch: expected {expected}, got {actual}")]
36    BufferSizeMismatch { expected: usize, actual: usize },
37    /// Image dimensions are zero or invalid.
38    #[error("Invalid dimensions: {width}x{height}")]
39    InvalidDimensions { width: u32, height: u32 },
40    /// Pixel count would overflow.
41    #[error("Pixel count overflow")]
42    PixelCountOverflow,
43    /// Planar planes are inconsistent in length.
44    #[error("Planar plane size mismatch: plane '{plane}' has {actual} bytes, expected {expected}")]
45    PlanarMismatch {
46        plane: &'static str,
47        expected: usize,
48        actual: usize,
49    },
50}
51
52// ─── ScaleFilter ──────────────────────────────────────────────────────────────
53
54/// Interpolation filter used when scaling.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum ScaleFilter {
57    /// Nearest-neighbor: fast, blocky.
58    Nearest,
59    /// Bilinear: smooth, slight blur.
60    Bilinear,
61    /// Bicubic (Keys, a = -0.5): sharper than bilinear.
62    Bicubic,
63    /// Area averaging: best for significant downscale (anti-aliasing).
64    Area,
65}
66
67impl ScaleFilter {
68    /// Human-readable label.
69    #[must_use]
70    pub fn label(self) -> &'static str {
71        match self {
72            Self::Nearest => "nearest",
73            Self::Bilinear => "bilinear",
74            Self::Bicubic => "bicubic",
75            Self::Area => "area",
76        }
77    }
78}
79
80// ─── ScaleStats ───────────────────────────────────────────────────────────────
81
82/// Statistics produced after a scaling operation.
83#[derive(Debug, Clone, Default)]
84pub struct ScaleStats {
85    /// Number of destination pixels computed.
86    pub dst_pixels: u64,
87    /// Number of source pixels read (may exceed dst_pixels for area filter).
88    pub src_pixels_read: u64,
89    /// Filter used.
90    pub filter: Option<ScaleFilter>,
91}
92
93// ─── ScaleKernel ─────────────────────────────────────────────────────────────
94
95/// GPU-style image scaling kernel (CPU simulation via Rayon).
96///
97/// Works with packed RGBA (4 bytes / pixel) and planar single-channel data.
98#[derive(Debug, Clone, Default)]
99pub struct ScaleKernel;
100
101impl ScaleKernel {
102    /// Validate a packed RGBA buffer against given dimensions.
103    fn validate_rgba(buf: &[u8], width: u32, height: u32) -> Result<usize, ScaleError> {
104        if width == 0 || height == 0 {
105            return Err(ScaleError::InvalidDimensions { width, height });
106        }
107        let pixels = (width as usize)
108            .checked_mul(height as usize)
109            .ok_or(ScaleError::PixelCountOverflow)?;
110        let expected = pixels * 4;
111        if buf.len() != expected {
112            return Err(ScaleError::BufferSizeMismatch {
113                expected,
114                actual: buf.len(),
115            });
116        }
117        Ok(pixels)
118    }
119
120    /// Validate a planar (single-channel) buffer.
121    fn validate_plane(
122        buf: &[u8],
123        width: u32,
124        height: u32,
125        name: &'static str,
126    ) -> Result<usize, ScaleError> {
127        if width == 0 || height == 0 {
128            return Err(ScaleError::InvalidDimensions { width, height });
129        }
130        let expected = (width as usize)
131            .checked_mul(height as usize)
132            .ok_or(ScaleError::PixelCountOverflow)?;
133        if buf.len() != expected {
134            return Err(ScaleError::PlanarMismatch {
135                plane: name,
136                expected,
137                actual: buf.len(),
138            });
139        }
140        Ok(expected)
141    }
142
143    // ── Core scaling logic ────────────────────────────────────────────────────
144
145    /// Scale a single-channel (planar) image using the specified filter.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`ScaleError`] if any buffer or dimension is invalid.
150    pub fn scale_plane(
151        src: &[u8],
152        src_w: u32,
153        src_h: u32,
154        dst: &mut [u8],
155        dst_w: u32,
156        dst_h: u32,
157        filter: ScaleFilter,
158    ) -> Result<ScaleStats, ScaleError> {
159        Self::validate_plane(src, src_w, src_h, "src")?;
160        let dst_pixels = Self::validate_plane(dst, dst_w, dst_h, "dst")?;
161
162        let scale_x = src_w as f32 / dst_w as f32;
163        let scale_y = src_h as f32 / dst_h as f32;
164
165        dst.par_iter_mut().enumerate().for_each(|(i, out)| {
166            let dy = (i / dst_w as usize) as f32;
167            let dx = (i % dst_w as usize) as f32;
168            *out = match filter {
169                ScaleFilter::Nearest => {
170                    sample_nearest_plane(src, src_w, src_h, dx, dy, scale_x, scale_y)
171                }
172                ScaleFilter::Bilinear => {
173                    sample_bilinear_plane(src, src_w, src_h, dx, dy, scale_x, scale_y)
174                }
175                ScaleFilter::Bicubic => {
176                    sample_bicubic_plane(src, src_w, src_h, dx, dy, scale_x, scale_y)
177                }
178                ScaleFilter::Area => sample_area_plane(src, src_w, src_h, dx, dy, scale_x, scale_y),
179            };
180        });
181
182        Ok(ScaleStats {
183            dst_pixels: dst_pixels as u64,
184            src_pixels_read: (src_w * src_h) as u64,
185            filter: Some(filter),
186        })
187    }
188
189    /// Scale a packed RGBA image.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`ScaleError`] if buffer or dimension validation fails.
194    pub fn scale_rgba(
195        src: &[u8],
196        src_w: u32,
197        src_h: u32,
198        dst: &mut [u8],
199        dst_w: u32,
200        dst_h: u32,
201        filter: ScaleFilter,
202    ) -> Result<ScaleStats, ScaleError> {
203        Self::validate_rgba(src, src_w, src_h)?;
204        let dst_pixels = Self::validate_rgba(dst, dst_w, dst_h)?;
205
206        let scale_x = src_w as f32 / dst_w as f32;
207        let scale_y = src_h as f32 / dst_h as f32;
208
209        dst.par_chunks_mut(4).enumerate().for_each(|(i, out_px)| {
210            let dy = (i / dst_w as usize) as f32;
211            let dx = (i % dst_w as usize) as f32;
212
213            match filter {
214                ScaleFilter::Nearest => {
215                    sample_nearest_rgba(src, src_w, src_h, dx, dy, scale_x, scale_y, out_px);
216                }
217                ScaleFilter::Bilinear => {
218                    sample_bilinear_rgba(src, src_w, src_h, dx, dy, scale_x, scale_y, out_px);
219                }
220                ScaleFilter::Bicubic => {
221                    sample_bicubic_rgba(src, src_w, src_h, dx, dy, scale_x, scale_y, out_px);
222                }
223                ScaleFilter::Area => {
224                    sample_area_rgba(src, src_w, src_h, dx, dy, scale_x, scale_y, out_px);
225                }
226            }
227        });
228
229        Ok(ScaleStats {
230            dst_pixels: dst_pixels as u64,
231            src_pixels_read: (src_w * src_h) as u64,
232            filter: Some(filter),
233        })
234    }
235
236    /// Scale a planar YUV 4:2:0 image.
237    ///
238    /// Cb/Cr planes are at half resolution in each dimension.
239    /// Returns scaled `(Y, Cb, Cr)` planes.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`ScaleError`] on dimension or buffer validation failure.
244    pub fn scale_yuv420(
245        y_src: &[u8],
246        cb_src: &[u8],
247        cr_src: &[u8],
248        src_w: u32,
249        src_h: u32,
250        dst_w: u32,
251        dst_h: u32,
252        filter: ScaleFilter,
253    ) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), ScaleError> {
254        // Y plane: full resolution
255        Self::validate_plane(y_src, src_w, src_h, "Y_src")?;
256        // Cb/Cr planes: half resolution
257        let chroma_src_w = (src_w + 1) / 2;
258        let chroma_src_h = (src_h + 1) / 2;
259        Self::validate_plane(cb_src, chroma_src_w, chroma_src_h, "Cb_src")?;
260        Self::validate_plane(cr_src, chroma_src_w, chroma_src_h, "Cr_src")?;
261
262        let chroma_dst_w = (dst_w + 1) / 2;
263        let chroma_dst_h = (dst_h + 1) / 2;
264
265        // Scale Y
266        let mut y_dst = vec![0u8; (dst_w * dst_h) as usize];
267        Self::scale_plane(y_src, src_w, src_h, &mut y_dst, dst_w, dst_h, filter)?;
268
269        // Scale Cb
270        let mut cb_dst = vec![0u8; (chroma_dst_w * chroma_dst_h) as usize];
271        Self::scale_plane(
272            cb_src,
273            chroma_src_w,
274            chroma_src_h,
275            &mut cb_dst,
276            chroma_dst_w,
277            chroma_dst_h,
278            filter,
279        )?;
280
281        // Scale Cr
282        let mut cr_dst = vec![0u8; (chroma_dst_w * chroma_dst_h) as usize];
283        Self::scale_plane(
284            cr_src,
285            chroma_src_w,
286            chroma_src_h,
287            &mut cr_dst,
288            chroma_dst_w,
289            chroma_dst_h,
290            filter,
291        )?;
292
293        Ok((y_dst, cb_dst, cr_dst))
294    }
295
296    /// Scale a planar YUV 4:2:2 image.
297    ///
298    /// Cb/Cr planes are at half width, full height.
299    /// Returns scaled `(Y, Cb, Cr)` planes.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`ScaleError`] on validation failure.
304    pub fn scale_yuv422(
305        y_src: &[u8],
306        cb_src: &[u8],
307        cr_src: &[u8],
308        src_w: u32,
309        src_h: u32,
310        dst_w: u32,
311        dst_h: u32,
312        filter: ScaleFilter,
313    ) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), ScaleError> {
314        Self::validate_plane(y_src, src_w, src_h, "Y_src")?;
315        let chroma_src_w = (src_w + 1) / 2;
316        Self::validate_plane(cb_src, chroma_src_w, src_h, "Cb_src")?;
317        Self::validate_plane(cr_src, chroma_src_w, src_h, "Cr_src")?;
318
319        let chroma_dst_w = (dst_w + 1) / 2;
320
321        let mut y_dst = vec![0u8; (dst_w * dst_h) as usize];
322        Self::scale_plane(y_src, src_w, src_h, &mut y_dst, dst_w, dst_h, filter)?;
323
324        let mut cb_dst = vec![0u8; (chroma_dst_w * dst_h) as usize];
325        Self::scale_plane(
326            cb_src,
327            chroma_src_w,
328            src_h,
329            &mut cb_dst,
330            chroma_dst_w,
331            dst_h,
332            filter,
333        )?;
334
335        let mut cr_dst = vec![0u8; (chroma_dst_w * dst_h) as usize];
336        Self::scale_plane(
337            cr_src,
338            chroma_src_w,
339            src_h,
340            &mut cr_dst,
341            chroma_dst_w,
342            dst_h,
343            filter,
344        )?;
345
346        Ok((y_dst, cb_dst, cr_dst))
347    }
348}
349
350// ─── Sampling helpers — planar ────────────────────────────────────────────────
351
352/// Clamp a pixel coordinate to the valid range `[0, max-1]`.
353#[inline]
354fn clamp_coord(v: i32, max: u32) -> usize {
355    v.clamp(0, max as i32 - 1) as usize
356}
357
358/// Read one byte from a planar buffer at `(x, y)` (clamped).
359#[inline]
360fn read_plane(src: &[u8], w: u32, _h: u32, x: i32, y: i32) -> f32 {
361    let xi = x.clamp(0, w as i32 - 1) as usize;
362    let yi = y.clamp(0, _h as i32 - 1) as usize;
363    src[yi * w as usize + xi] as f32
364}
365
366/// Nearest-neighbor sample from a plane.
367fn sample_nearest_plane(
368    src: &[u8],
369    src_w: u32,
370    src_h: u32,
371    dx: f32,
372    dy: f32,
373    scale_x: f32,
374    scale_y: f32,
375) -> u8 {
376    let sx = ((dx + 0.5) * scale_x - 0.5).round() as i32;
377    let sy = ((dy + 0.5) * scale_y - 0.5).round() as i32;
378    let xi = clamp_coord(sx, src_w);
379    let yi = clamp_coord(sy, src_h);
380    src[yi * src_w as usize + xi]
381}
382
383/// Bilinear sample from a plane.
384fn sample_bilinear_plane(
385    src: &[u8],
386    src_w: u32,
387    src_h: u32,
388    dx: f32,
389    dy: f32,
390    scale_x: f32,
391    scale_y: f32,
392) -> u8 {
393    let sx = (dx + 0.5) * scale_x - 0.5;
394    let sy = (dy + 0.5) * scale_y - 0.5;
395    let x0 = sx.floor() as i32;
396    let y0 = sy.floor() as i32;
397    let wx = sx - x0 as f32;
398    let wy = sy - y0 as f32;
399
400    let v00 = read_plane(src, src_w, src_h, x0, y0);
401    let v10 = read_plane(src, src_w, src_h, x0 + 1, y0);
402    let v01 = read_plane(src, src_w, src_h, x0, y0 + 1);
403    let v11 = read_plane(src, src_w, src_h, x0 + 1, y0 + 1);
404
405    let result = v00 * (1.0 - wx) * (1.0 - wy)
406        + v10 * wx * (1.0 - wy)
407        + v01 * (1.0 - wx) * wy
408        + v11 * wx * wy;
409    result.round().clamp(0.0, 255.0) as u8
410}
411
412/// Keys bicubic weight function (a = -0.5).
413#[inline]
414fn bicubic_weight(t: f32) -> f32 {
415    let t = t.abs();
416    let a = -0.5_f32;
417    if t <= 1.0 {
418        (a + 2.0) * t * t * t - (a + 3.0) * t * t + 1.0
419    } else if t < 2.0 {
420        a * t * t * t - 5.0 * a * t * t + 8.0 * a * t - 4.0 * a
421    } else {
422        0.0
423    }
424}
425
426/// Bicubic sample from a plane using 4×4 tap filter.
427fn sample_bicubic_plane(
428    src: &[u8],
429    src_w: u32,
430    src_h: u32,
431    dx: f32,
432    dy: f32,
433    scale_x: f32,
434    scale_y: f32,
435) -> u8 {
436    let sx = (dx + 0.5) * scale_x - 0.5;
437    let sy = (dy + 0.5) * scale_y - 0.5;
438    let x0 = sx.floor() as i32;
439    let y0 = sy.floor() as i32;
440
441    let mut sum = 0.0_f32;
442    let mut weight_sum = 0.0_f32;
443    for ky in -1_i32..=2 {
444        let wy = bicubic_weight(sy - (y0 + ky) as f32);
445        for kx in -1_i32..=2 {
446            let wx = bicubic_weight(sx - (x0 + kx) as f32);
447            let w = wx * wy;
448            sum += read_plane(src, src_w, src_h, x0 + kx, y0 + ky) * w;
449            weight_sum += w;
450        }
451    }
452
453    if weight_sum.abs() < 1e-9 {
454        return 0;
455    }
456    (sum / weight_sum).round().clamp(0.0, 255.0) as u8
457}
458
459/// Area-average sample from a plane (good for downscale).
460fn sample_area_plane(
461    src: &[u8],
462    src_w: u32,
463    src_h: u32,
464    dx: f32,
465    dy: f32,
466    scale_x: f32,
467    scale_y: f32,
468) -> u8 {
469    // Source region corresponding to this destination pixel.
470    let sx0 = dx * scale_x;
471    let sy0 = dy * scale_y;
472    let sx1 = (dx + 1.0) * scale_x;
473    let sy1 = (dy + 1.0) * scale_y;
474
475    let xi0 = sx0.floor() as i32;
476    let yi0 = sy0.floor() as i32;
477    let xi1 = (sx1.ceil() as i32).min(src_w as i32);
478    let yi1 = (sy1.ceil() as i32).min(src_h as i32);
479
480    // For upscale or 1:1, fall back to bilinear.
481    if xi1 <= xi0 + 1 && yi1 <= yi0 + 1 {
482        return sample_bilinear_plane(src, src_w, src_h, dx, dy, scale_x, scale_y);
483    }
484
485    let mut sum = 0.0_f32;
486    let mut total_weight = 0.0_f32;
487    for sy in yi0..yi1 {
488        let wy = partial_coverage(sy as f32, sy0, sy1);
489        for sx in xi0..xi1 {
490            let wx = partial_coverage(sx as f32, sx0, sx1);
491            let w = wx * wy;
492            sum += read_plane(src, src_w, src_h, sx, sy) * w;
493            total_weight += w;
494        }
495    }
496    if total_weight < 1e-9 {
497        return 0;
498    }
499    (sum / total_weight).round().clamp(0.0, 255.0) as u8
500}
501
502/// Fraction of a `[start, end)` interval covered by pixel `[p, p+1)`.
503#[inline]
504fn partial_coverage(p: f32, start: f32, end: f32) -> f32 {
505    let lo = p.max(start);
506    let hi = (p + 1.0).min(end);
507    (hi - lo).max(0.0)
508}
509
510// ─── Sampling helpers — packed RGBA ──────────────────────────────────────────
511
512/// Read one RGBA pixel from a packed buffer (clamped coords).
513#[inline]
514fn read_rgba(src: &[u8], w: u32, h: u32, x: i32, y: i32) -> [f32; 4] {
515    let xi = x.clamp(0, w as i32 - 1) as usize;
516    let yi = y.clamp(0, h as i32 - 1) as usize;
517    let base = (yi * w as usize + xi) * 4;
518    [
519        src[base] as f32,
520        src[base + 1] as f32,
521        src[base + 2] as f32,
522        src[base + 3] as f32,
523    ]
524}
525
526fn sample_nearest_rgba(
527    src: &[u8],
528    src_w: u32,
529    src_h: u32,
530    dx: f32,
531    dy: f32,
532    scale_x: f32,
533    scale_y: f32,
534    out: &mut [u8],
535) {
536    let sx = ((dx + 0.5) * scale_x - 0.5).round() as i32;
537    let sy = ((dy + 0.5) * scale_y - 0.5).round() as i32;
538    let px = read_rgba(src, src_w, src_h, sx, sy);
539    for (o, &v) in out.iter_mut().zip(px.iter()) {
540        *o = v.round().clamp(0.0, 255.0) as u8;
541    }
542}
543
544fn sample_bilinear_rgba(
545    src: &[u8],
546    src_w: u32,
547    src_h: u32,
548    dx: f32,
549    dy: f32,
550    scale_x: f32,
551    scale_y: f32,
552    out: &mut [u8],
553) {
554    let sx = (dx + 0.5) * scale_x - 0.5;
555    let sy = (dy + 0.5) * scale_y - 0.5;
556    let x0 = sx.floor() as i32;
557    let y0 = sy.floor() as i32;
558    let wx = sx - x0 as f32;
559    let wy = sy - y0 as f32;
560
561    let v00 = read_rgba(src, src_w, src_h, x0, y0);
562    let v10 = read_rgba(src, src_w, src_h, x0 + 1, y0);
563    let v01 = read_rgba(src, src_w, src_h, x0, y0 + 1);
564    let v11 = read_rgba(src, src_w, src_h, x0 + 1, y0 + 1);
565
566    for c in 0..4 {
567        let r = v00[c] * (1.0 - wx) * (1.0 - wy)
568            + v10[c] * wx * (1.0 - wy)
569            + v01[c] * (1.0 - wx) * wy
570            + v11[c] * wx * wy;
571        out[c] = r.round().clamp(0.0, 255.0) as u8;
572    }
573}
574
575fn sample_bicubic_rgba(
576    src: &[u8],
577    src_w: u32,
578    src_h: u32,
579    dx: f32,
580    dy: f32,
581    scale_x: f32,
582    scale_y: f32,
583    out: &mut [u8],
584) {
585    let sx = (dx + 0.5) * scale_x - 0.5;
586    let sy = (dy + 0.5) * scale_y - 0.5;
587    let x0 = sx.floor() as i32;
588    let y0 = sy.floor() as i32;
589
590    let mut sum = [0.0_f32; 4];
591    let mut weight_sum = 0.0_f32;
592    for ky in -1_i32..=2 {
593        let wy = bicubic_weight(sy - (y0 + ky) as f32);
594        for kx in -1_i32..=2 {
595            let wx = bicubic_weight(sx - (x0 + kx) as f32);
596            let w = wx * wy;
597            let px = read_rgba(src, src_w, src_h, x0 + kx, y0 + ky);
598            for c in 0..4 {
599                sum[c] += px[c] * w;
600            }
601            weight_sum += w;
602        }
603    }
604    for c in 0..4 {
605        let v = if weight_sum.abs() > 1e-9 {
606            sum[c] / weight_sum
607        } else {
608            0.0
609        };
610        out[c] = v.round().clamp(0.0, 255.0) as u8;
611    }
612}
613
614fn sample_area_rgba(
615    src: &[u8],
616    src_w: u32,
617    src_h: u32,
618    dx: f32,
619    dy: f32,
620    scale_x: f32,
621    scale_y: f32,
622    out: &mut [u8],
623) {
624    let sx0 = dx * scale_x;
625    let sy0 = dy * scale_y;
626    let sx1 = (dx + 1.0) * scale_x;
627    let sy1 = (dy + 1.0) * scale_y;
628
629    let xi0 = sx0.floor() as i32;
630    let yi0 = sy0.floor() as i32;
631    let xi1 = (sx1.ceil() as i32).min(src_w as i32);
632    let yi1 = (sy1.ceil() as i32).min(src_h as i32);
633
634    if xi1 <= xi0 + 1 && yi1 <= yi0 + 1 {
635        sample_bilinear_rgba(src, src_w, src_h, dx, dy, scale_x, scale_y, out);
636        return;
637    }
638
639    let mut sum = [0.0_f32; 4];
640    let mut total_weight = 0.0_f32;
641    for sy in yi0..yi1 {
642        let wy = partial_coverage(sy as f32, sy0, sy1);
643        for sx in xi0..xi1 {
644            let wx = partial_coverage(sx as f32, sx0, sx1);
645            let w = wx * wy;
646            let px = read_rgba(src, src_w, src_h, sx, sy);
647            for c in 0..4 {
648                sum[c] += px[c] * w;
649            }
650            total_weight += w;
651        }
652    }
653    for c in 0..4 {
654        let v = if total_weight > 1e-9 {
655            sum[c] / total_weight
656        } else {
657            0.0
658        };
659        out[c] = v.round().clamp(0.0, 255.0) as u8;
660    }
661}
662
663// ─── Tests ───────────────────────────────────────────────────────────────────
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    // ── ScaleFilter ───────────────────────────────────────────────────────────
670
671    #[test]
672    fn test_scale_filter_labels() {
673        assert_eq!(ScaleFilter::Nearest.label(), "nearest");
674        assert_eq!(ScaleFilter::Bilinear.label(), "bilinear");
675        assert_eq!(ScaleFilter::Bicubic.label(), "bicubic");
676        assert_eq!(ScaleFilter::Area.label(), "area");
677    }
678
679    // ── Error handling ────────────────────────────────────────────────────────
680
681    #[test]
682    fn test_scale_rgba_invalid_src_dims() {
683        let src = vec![0u8; 4];
684        let mut dst = vec![0u8; 4];
685        let err = ScaleKernel::scale_rgba(&src, 0, 1, &mut dst, 1, 1, ScaleFilter::Nearest);
686        assert!(matches!(err, Err(ScaleError::InvalidDimensions { .. })));
687    }
688
689    #[test]
690    fn test_scale_rgba_buffer_mismatch() {
691        let src = vec![0u8; 8]; // wrong: 1×1 = 4 bytes
692        let mut dst = vec![0u8; 4];
693        let err = ScaleKernel::scale_rgba(&src, 1, 1, &mut dst, 1, 1, ScaleFilter::Bilinear);
694        assert!(matches!(err, Err(ScaleError::BufferSizeMismatch { .. })));
695    }
696
697    #[test]
698    fn test_scale_plane_invalid_dims() {
699        let src = vec![0u8; 1];
700        let mut dst = vec![0u8; 4];
701        let err = ScaleKernel::scale_plane(&src, 0, 0, &mut dst, 2, 2, ScaleFilter::Nearest);
702        assert!(matches!(err, Err(ScaleError::InvalidDimensions { .. })));
703    }
704
705    // ── 1:1 identity scale ────────────────────────────────────────────────────
706
707    fn identity_scale(filter: ScaleFilter) {
708        // A simple gradient 4×4 RGBA image.
709        let src: Vec<u8> = (0..4 * 4 * 4).map(|i| (i * 7 % 256) as u8).collect();
710        let mut dst = vec![0u8; 4 * 4 * 4];
711        ScaleKernel::scale_rgba(&src, 4, 4, &mut dst, 4, 4, filter).unwrap();
712        // Identity scale must reproduce the source exactly (or with 1 LSB for filtered).
713        for (i, (&s, &d)) in src.iter().zip(dst.iter()).enumerate() {
714            let diff = (s as i16 - d as i16).abs();
715            assert!(
716                diff <= 1,
717                "filter={}: pixel {i}: src={s} dst={d}",
718                filter.label()
719            );
720        }
721    }
722
723    #[test]
724    fn test_identity_nearest() {
725        identity_scale(ScaleFilter::Nearest);
726    }
727
728    #[test]
729    fn test_identity_bilinear() {
730        identity_scale(ScaleFilter::Bilinear);
731    }
732
733    #[test]
734    fn test_identity_bicubic() {
735        identity_scale(ScaleFilter::Bicubic);
736    }
737
738    // ── Downscale ─────────────────────────────────────────────────────────────
739
740    #[test]
741    fn test_downscale_2x_preserves_constant_color() {
742        // Solid red 8×8 RGBA
743        let src: Vec<u8> = (0..8 * 8).flat_map(|_| [255u8, 0, 0, 255]).collect();
744        let mut dst = vec![0u8; 4 * 4 * 4];
745        ScaleKernel::scale_rgba(&src, 8, 8, &mut dst, 4, 4, ScaleFilter::Bilinear).unwrap();
746        for px in dst.chunks(4) {
747            assert_eq!(px[0], 255, "R should be 255");
748            assert_eq!(px[1], 0, "G should be 0");
749            assert_eq!(px[2], 0, "B should be 0");
750            assert_eq!(px[3], 255, "A should be 255");
751        }
752    }
753
754    #[test]
755    fn test_downscale_output_size() {
756        let src = vec![128u8; 64 * 64 * 4];
757        let mut dst = vec![0u8; 32 * 32 * 4];
758        let stats =
759            ScaleKernel::scale_rgba(&src, 64, 64, &mut dst, 32, 32, ScaleFilter::Area).unwrap();
760        assert_eq!(stats.dst_pixels, 32 * 32);
761    }
762
763    // ── Upscale ───────────────────────────────────────────────────────────────
764
765    #[test]
766    fn test_upscale_output_size() {
767        let src = vec![64u8; 8 * 8 * 4];
768        let mut dst = vec![0u8; 16 * 16 * 4];
769        let stats =
770            ScaleKernel::scale_rgba(&src, 8, 8, &mut dst, 16, 16, ScaleFilter::Bicubic).unwrap();
771        assert_eq!(stats.dst_pixels, 16 * 16);
772    }
773
774    // ── Plane scaling ─────────────────────────────────────────────────────────
775
776    #[test]
777    fn test_scale_plane_constant_value() {
778        let src = vec![200u8; 16 * 16];
779        let mut dst = vec![0u8; 8 * 8];
780        ScaleKernel::scale_plane(&src, 16, 16, &mut dst, 8, 8, ScaleFilter::Bilinear).unwrap();
781        assert!(
782            dst.iter().all(|&v| v == 200),
783            "constant plane should stay constant"
784        );
785    }
786
787    // ── YUV 4:2:0 scaling ─────────────────────────────────────────────────────
788
789    #[test]
790    fn test_scale_yuv420_output_sizes() {
791        let y_src = vec![128u8; 8 * 8];
792        let cb_src = vec![128u8; 4 * 4];
793        let cr_src = vec![128u8; 4 * 4];
794        let (y_dst, cb_dst, cr_dst) =
795            ScaleKernel::scale_yuv420(&y_src, &cb_src, &cr_src, 8, 8, 4, 4, ScaleFilter::Bilinear)
796                .unwrap();
797        assert_eq!(y_dst.len(), 4 * 4);
798        assert_eq!(cb_dst.len(), 2 * 2);
799        assert_eq!(cr_dst.len(), 2 * 2);
800    }
801
802    #[test]
803    fn test_scale_yuv420_constant_neutral() {
804        let y_src = vec![128u8; 8 * 8];
805        let cb_src = vec![128u8; 4 * 4];
806        let cr_src = vec![128u8; 4 * 4];
807        let (y_dst, cb_dst, cr_dst) =
808            ScaleKernel::scale_yuv420(&y_src, &cb_src, &cr_src, 8, 8, 16, 16, ScaleFilter::Nearest)
809                .unwrap();
810        assert_eq!(y_dst.len(), 16 * 16);
811        assert!(y_dst.iter().all(|&v| v == 128));
812        assert!(cb_dst.iter().all(|&v| v == 128));
813        assert!(cr_dst.iter().all(|&v| v == 128));
814    }
815
816    // ── YUV 4:2:2 scaling ─────────────────────────────────────────────────────
817
818    #[test]
819    fn test_scale_yuv422_output_sizes() {
820        let y_src = vec![128u8; 8 * 4]; // 8×4
821        let cb_src = vec![128u8; 4 * 4]; // 4×4
822        let cr_src = vec![128u8; 4 * 4]; // 4×4
823        let (y_dst, cb_dst, cr_dst) =
824            ScaleKernel::scale_yuv422(&y_src, &cb_src, &cr_src, 8, 4, 4, 2, ScaleFilter::Bilinear)
825                .unwrap();
826        assert_eq!(y_dst.len(), 4 * 2);
827        assert_eq!(cb_dst.len(), 2 * 2);
828        assert_eq!(cr_dst.len(), 2 * 2);
829    }
830
831    // ── bicubic_weight unit test ──────────────────────────────────────────────
832
833    #[test]
834    fn test_bicubic_weight_at_zero_is_one() {
835        assert!((bicubic_weight(0.0) - 1.0).abs() < 1e-6);
836    }
837
838    #[test]
839    fn test_bicubic_weight_at_two_is_zero() {
840        assert!(bicubic_weight(2.0).abs() < 1e-6);
841    }
842
843    // ── stats ─────────────────────────────────────────────────────────────────
844
845    #[test]
846    fn test_scale_stats_filter_recorded() {
847        let src = vec![0u8; 4 * 4 * 4];
848        let mut dst = vec![0u8; 2 * 2 * 4];
849        let stats = ScaleKernel::scale_rgba(&src, 4, 4, &mut dst, 2, 2, ScaleFilter::Area).unwrap();
850        assert_eq!(stats.filter, Some(ScaleFilter::Area));
851        assert_eq!(stats.dst_pixels, 4);
852    }
853}