pixo/
resize.rs

1//! Image resizing algorithms.
2//!
3//! This module provides high-quality image resizing with multiple algorithms:
4//! - **Nearest neighbor**: Fastest, pixelated results (good for pixel art)
5//! - **Bilinear**: Fast, smooth results (good balance)
6//! - **Lanczos3**: Highest quality, slower (best for photographs)
7//!
8//! # Example
9//!
10//! ```rust
11//! use pixo::resize::{resize, ResizeOptions, ResizeAlgorithm};
12//! use pixo::ColorType;
13//!
14//! // Resize a 100x100 RGBA image to 50x50 using Lanczos3
15//! let pixels = vec![128u8; 100 * 100 * 4];
16//! let options = ResizeOptions::builder(100, 100)
17//!     .dst(50, 50)
18//!     .color_type(ColorType::Rgba)
19//!     .algorithm(ResizeAlgorithm::Lanczos3)
20//!     .build();
21//! let resized = resize(&pixels, &options).unwrap();
22//! assert_eq!(resized.len(), 50 * 50 * 4);
23//! ```
24
25use crate::color::ColorType;
26use crate::error::{Error, Result};
27use std::f32::consts::PI;
28
29/// Maximum supported dimension for resizing.
30const MAX_DIMENSION: u32 = 1 << 24; // 16 million pixels
31
32/// Resizing algorithm to use.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum ResizeAlgorithm {
35    /// Nearest neighbor: fastest, pixelated results.
36    /// Best for pixel art or when speed is critical.
37    Nearest,
38    /// Bilinear interpolation: fast with smooth results.
39    /// Good balance between quality and speed.
40    #[default]
41    Bilinear,
42    /// Lanczos3 resampling: highest quality, slowest.
43    /// Best for photographs and high-quality downscaling.
44    Lanczos3,
45}
46
47/// Options for image resizing operations.
48///
49/// Use [`ResizeOptions::builder()`] to create options with a fluent API.
50///
51/// # Example
52///
53/// ```rust
54/// use pixo::resize::{resize, ResizeOptions, ResizeAlgorithm};
55/// use pixo::ColorType;
56///
57/// let pixels = vec![128u8; 100 * 100 * 4];
58/// let options = ResizeOptions::builder(100, 100)
59///     .dst(50, 50)
60///     .color_type(ColorType::Rgba)
61///     .algorithm(ResizeAlgorithm::Lanczos3)
62///     .build();
63/// let resized = resize(&pixels, &options).unwrap();
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct ResizeOptions {
67    /// Source image width in pixels.
68    pub src_width: u32,
69    /// Source image height in pixels.
70    pub src_height: u32,
71    /// Destination image width in pixels.
72    pub dst_width: u32,
73    /// Destination image height in pixels.
74    pub dst_height: u32,
75    /// Color type of the pixel data.
76    pub color_type: ColorType,
77    /// Resizing algorithm to use.
78    pub algorithm: ResizeAlgorithm,
79}
80
81impl ResizeOptions {
82    /// Create a builder for [`ResizeOptions`].
83    ///
84    /// The source dimensions are required; destination defaults to matching source
85    /// (no resize), color type defaults to RGBA, and algorithm defaults to Bilinear.
86    pub fn builder(src_width: u32, src_height: u32) -> ResizeOptionsBuilder {
87        ResizeOptionsBuilder::new(src_width, src_height)
88    }
89}
90
91/// Builder for [`ResizeOptions`].
92#[derive(Debug, Clone)]
93pub struct ResizeOptionsBuilder {
94    src_width: u32,
95    src_height: u32,
96    dst_width: u32,
97    dst_height: u32,
98    color_type: ColorType,
99    algorithm: ResizeAlgorithm,
100}
101
102impl ResizeOptionsBuilder {
103    /// Create a new builder with source dimensions.
104    pub fn new(src_width: u32, src_height: u32) -> Self {
105        Self {
106            src_width,
107            src_height,
108            dst_width: src_width,
109            dst_height: src_height,
110            color_type: ColorType::Rgba,
111            algorithm: ResizeAlgorithm::default(),
112        }
113    }
114
115    /// Set the destination dimensions.
116    pub fn dst(mut self, width: u32, height: u32) -> Self {
117        self.dst_width = width;
118        self.dst_height = height;
119        self
120    }
121
122    /// Set the color type of the pixel data.
123    pub fn color_type(mut self, color_type: ColorType) -> Self {
124        self.color_type = color_type;
125        self
126    }
127
128    /// Set the resizing algorithm.
129    pub fn algorithm(mut self, algorithm: ResizeAlgorithm) -> Self {
130        self.algorithm = algorithm;
131        self
132    }
133
134    /// Build the [`ResizeOptions`].
135    #[must_use]
136    pub fn build(self) -> ResizeOptions {
137        ResizeOptions {
138            src_width: self.src_width,
139            src_height: self.src_height,
140            dst_width: self.dst_width,
141            dst_height: self.dst_height,
142            color_type: self.color_type,
143            algorithm: self.algorithm,
144        }
145    }
146}
147
148/// Resize an image to new dimensions.
149///
150/// # Arguments
151///
152/// * `data` - Raw pixel data (row-major order)
153/// * `options` - Resize options specifying dimensions, color type, and algorithm
154///
155/// # Returns
156///
157/// Resized pixel data with the same color type.
158///
159/// # Errors
160///
161/// Returns an error if dimensions are invalid or data length doesn't match.
162#[must_use = "resizing produces pixel data that should be used"]
163pub fn resize(data: &[u8], options: &ResizeOptions) -> Result<Vec<u8>> {
164    let mut output = Vec::new();
165    resize_into(&mut output, data, options)?;
166    Ok(output)
167}
168
169/// Resize an image into a caller-provided buffer.
170///
171/// The `output` buffer will be cleared and reused, allowing callers to avoid
172/// repeated allocations across multiple resize operations.
173///
174/// # Arguments
175///
176/// * `output` - Buffer to write resized data into (will be cleared)
177/// * `data` - Raw pixel data (row-major order)
178/// * `options` - Resize options specifying dimensions, color type, and algorithm
179#[must_use = "this `Result` may indicate a resize error"]
180pub fn resize_into(output: &mut Vec<u8>, data: &[u8], options: &ResizeOptions) -> Result<()> {
181    resize_impl(
182        output,
183        data,
184        options.src_width,
185        options.src_height,
186        options.dst_width,
187        options.dst_height,
188        options.color_type,
189        options.algorithm,
190    )
191}
192
193/// Internal resize implementation.
194#[allow(clippy::too_many_arguments)]
195fn resize_impl(
196    output: &mut Vec<u8>,
197    data: &[u8],
198    src_width: u32,
199    src_height: u32,
200    dst_width: u32,
201    dst_height: u32,
202    color_type: ColorType,
203    algorithm: ResizeAlgorithm,
204) -> Result<()> {
205    // Validate source dimensions
206    if src_width == 0 || src_height == 0 {
207        return Err(Error::InvalidDimensions {
208            width: src_width,
209            height: src_height,
210        });
211    }
212
213    // Validate destination dimensions
214    if dst_width == 0 || dst_height == 0 {
215        return Err(Error::InvalidDimensions {
216            width: dst_width,
217            height: dst_height,
218        });
219    }
220
221    // Check maximum dimensions
222    if src_width > MAX_DIMENSION
223        || src_height > MAX_DIMENSION
224        || dst_width > MAX_DIMENSION
225        || dst_height > MAX_DIMENSION
226    {
227        return Err(Error::ImageTooLarge {
228            width: src_width.max(dst_width),
229            height: src_height.max(dst_height),
230            max: MAX_DIMENSION,
231        });
232    }
233
234    let bytes_per_pixel = color_type.bytes_per_pixel();
235
236    // Validate input data length
237    let expected_len = (src_width as usize)
238        .checked_mul(src_height as usize)
239        .and_then(|v| v.checked_mul(bytes_per_pixel))
240        .ok_or(Error::InvalidDataLength {
241            expected: usize::MAX,
242            actual: data.len(),
243        })?;
244
245    if data.len() != expected_len {
246        return Err(Error::InvalidDataLength {
247            expected: expected_len,
248            actual: data.len(),
249        });
250    }
251
252    // Calculate output size
253    let output_len = (dst_width as usize)
254        .checked_mul(dst_height as usize)
255        .and_then(|v| v.checked_mul(bytes_per_pixel))
256        .ok_or(Error::InvalidDataLength {
257            expected: usize::MAX,
258            actual: 0,
259        })?;
260
261    output.clear();
262    output.resize(output_len, 0);
263
264    // Dispatch to appropriate algorithm
265    match algorithm {
266        ResizeAlgorithm::Nearest => resize_nearest(
267            output,
268            data,
269            src_width as usize,
270            src_height as usize,
271            dst_width as usize,
272            dst_height as usize,
273            bytes_per_pixel,
274        ),
275        ResizeAlgorithm::Bilinear => resize_bilinear(
276            output,
277            data,
278            src_width as usize,
279            src_height as usize,
280            dst_width as usize,
281            dst_height as usize,
282            bytes_per_pixel,
283        ),
284        ResizeAlgorithm::Lanczos3 => resize_lanczos3(
285            output,
286            data,
287            src_width as usize,
288            src_height as usize,
289            dst_width as usize,
290            dst_height as usize,
291            bytes_per_pixel,
292        ),
293    }
294
295    Ok(())
296}
297
298/// Nearest neighbor resizing - fastest, pixelated results.
299fn resize_nearest(
300    output: &mut [u8],
301    data: &[u8],
302    src_width: usize,
303    src_height: usize,
304    dst_width: usize,
305    dst_height: usize,
306    bytes_per_pixel: usize,
307) {
308    let x_ratio = src_width as f32 / dst_width as f32;
309    let y_ratio = src_height as f32 / dst_height as f32;
310
311    for dst_y in 0..dst_height {
312        let src_y = ((dst_y as f32 + 0.5) * y_ratio - 0.5)
313            .round()
314            .max(0.0)
315            .min((src_height - 1) as f32) as usize;
316
317        for dst_x in 0..dst_width {
318            let src_x = ((dst_x as f32 + 0.5) * x_ratio - 0.5)
319                .round()
320                .max(0.0)
321                .min((src_width - 1) as f32) as usize;
322
323            let src_idx = (src_y * src_width + src_x) * bytes_per_pixel;
324            let dst_idx = (dst_y * dst_width + dst_x) * bytes_per_pixel;
325
326            output[dst_idx..dst_idx + bytes_per_pixel]
327                .copy_from_slice(&data[src_idx..src_idx + bytes_per_pixel]);
328        }
329    }
330}
331
332/// Bilinear interpolation - good balance of quality and speed.
333fn resize_bilinear(
334    output: &mut [u8],
335    data: &[u8],
336    src_width: usize,
337    src_height: usize,
338    dst_width: usize,
339    dst_height: usize,
340    bytes_per_pixel: usize,
341) {
342    let x_ratio = if dst_width > 1 {
343        (src_width - 1) as f32 / (dst_width - 1) as f32
344    } else {
345        0.0
346    };
347    let y_ratio = if dst_height > 1 {
348        (src_height - 1) as f32 / (dst_height - 1) as f32
349    } else {
350        0.0
351    };
352
353    for dst_y in 0..dst_height {
354        let src_y_f = dst_y as f32 * y_ratio;
355        let src_y0 = src_y_f.floor() as usize;
356        let src_y1 = (src_y0 + 1).min(src_height - 1);
357        let y_frac = src_y_f - src_y0 as f32;
358
359        for dst_x in 0..dst_width {
360            let src_x_f = dst_x as f32 * x_ratio;
361            let src_x0 = src_x_f.floor() as usize;
362            let src_x1 = (src_x0 + 1).min(src_width - 1);
363            let x_frac = src_x_f - src_x0 as f32;
364
365            // Get the four surrounding pixels
366            let idx00 = (src_y0 * src_width + src_x0) * bytes_per_pixel;
367            let idx01 = (src_y0 * src_width + src_x1) * bytes_per_pixel;
368            let idx10 = (src_y1 * src_width + src_x0) * bytes_per_pixel;
369            let idx11 = (src_y1 * src_width + src_x1) * bytes_per_pixel;
370
371            let dst_idx = (dst_y * dst_width + dst_x) * bytes_per_pixel;
372
373            // Interpolate each channel
374            for c in 0..bytes_per_pixel {
375                let p00 = data[idx00 + c] as f32;
376                let p01 = data[idx01 + c] as f32;
377                let p10 = data[idx10 + c] as f32;
378                let p11 = data[idx11 + c] as f32;
379
380                // Bilinear interpolation
381                let top = p00 * (1.0 - x_frac) + p01 * x_frac;
382                let bottom = p10 * (1.0 - x_frac) + p11 * x_frac;
383                let value = top * (1.0 - y_frac) + bottom * y_frac;
384
385                output[dst_idx + c] = value.round().clamp(0.0, 255.0) as u8;
386            }
387        }
388    }
389}
390
391/// Lanczos kernel function.
392#[inline]
393fn lanczos_kernel(x: f32, a: f32) -> f32 {
394    if x.abs() < f32::EPSILON {
395        1.0
396    } else if x.abs() >= a {
397        0.0
398    } else {
399        let pi_x = PI * x;
400        let pi_x_a = PI * x / a;
401        (a * pi_x.sin() * pi_x_a.sin()) / (pi_x * pi_x_a)
402    }
403}
404
405/// Precomputed contribution for a source pixel to destination pixels.
406#[derive(Clone)]
407struct Contribution {
408    /// Starting source pixel index
409    start: usize,
410    /// Weights for each source pixel (already normalized)
411    weights: Vec<f32>,
412}
413
414/// Precompute Lanczos3 contributions for a 1D resample.
415/// Returns a vec of Contribution, one for each destination pixel.
416fn precompute_contributions(src_size: usize, dst_size: usize) -> Vec<Contribution> {
417    const A: f32 = 3.0;
418
419    let scale = src_size as f32 / dst_size as f32;
420    // For downscaling, expand the kernel support proportionally
421    let filter_scale = scale.max(1.0);
422    let support = A * filter_scale;
423
424    let mut contributions = Vec::with_capacity(dst_size);
425
426    for dst_idx in 0..dst_size {
427        // Map destination pixel center to source coordinates
428        let src_center = (dst_idx as f32 + 0.5) * scale - 0.5;
429
430        // Determine the range of source pixels that contribute
431        let start = ((src_center - support).floor() as isize).max(0) as usize;
432        let end = ((src_center + support).ceil() as usize + 1).min(src_size);
433
434        // Compute weights
435        let mut weights = Vec::with_capacity(end - start);
436        let mut weight_sum = 0.0f32;
437
438        for src_idx in start..end {
439            let x = (src_idx as f32 - src_center) / filter_scale;
440            let w = lanczos_kernel(x, A);
441            weights.push(w);
442            weight_sum += w;
443        }
444
445        // Normalize weights
446        if weight_sum.abs() > f32::EPSILON {
447            for w in &mut weights {
448                *w /= weight_sum;
449            }
450        }
451
452        contributions.push(Contribution { start, weights });
453    }
454
455    contributions
456}
457
458/// Apply 1D horizontal resampling to a single row.
459#[inline]
460fn resample_row_horizontal(
461    src_row: &[u8],
462    dst_row: &mut [u8],
463    contributions: &[Contribution],
464    bytes_per_pixel: usize,
465) {
466    for (dst_x, contrib) in contributions.iter().enumerate() {
467        let dst_idx = dst_x * bytes_per_pixel;
468
469        // Accumulate weighted samples for each channel
470        let mut channel_sums = [0.0f32; 4];
471
472        for (i, &weight) in contrib.weights.iter().enumerate() {
473            let src_x = contrib.start + i;
474            let src_idx = src_x * bytes_per_pixel;
475            for c in 0..bytes_per_pixel {
476                channel_sums[c] += src_row[src_idx + c] as f32 * weight;
477            }
478        }
479
480        for c in 0..bytes_per_pixel {
481            dst_row[dst_idx + c] = channel_sums[c].round().clamp(0.0, 255.0) as u8;
482        }
483    }
484}
485
486/// Apply 1D vertical resampling to produce one destination row.
487#[inline]
488fn resample_column_vertical(
489    temp: &[u8],
490    dst_row: &mut [u8],
491    contrib: &Contribution,
492    temp_width: usize,
493    bytes_per_pixel: usize,
494) {
495    let row_stride = temp_width * bytes_per_pixel;
496
497    for dst_x in 0..temp_width {
498        let dst_idx = dst_x * bytes_per_pixel;
499        let mut channel_sums = [0.0f32; 4];
500
501        for (i, &weight) in contrib.weights.iter().enumerate() {
502            let src_y = contrib.start + i;
503            let src_idx = src_y * row_stride + dst_x * bytes_per_pixel;
504            for c in 0..bytes_per_pixel {
505                channel_sums[c] += temp[src_idx + c] as f32 * weight;
506            }
507        }
508
509        for c in 0..bytes_per_pixel {
510            dst_row[dst_idx + c] = channel_sums[c].round().clamp(0.0, 255.0) as u8;
511        }
512    }
513}
514
515/// Lanczos3 resampling using separable filtering (horizontal then vertical).
516/// This is O(2n) per pixel instead of O(n²), providing massive speedup.
517fn resize_lanczos3(
518    output: &mut [u8],
519    data: &[u8],
520    src_width: usize,
521    src_height: usize,
522    dst_width: usize,
523    dst_height: usize,
524    bytes_per_pixel: usize,
525) {
526    // Precompute contribution weights (computed once, reused for all rows/cols)
527    let h_contribs = precompute_contributions(src_width, dst_width);
528    let v_contribs = precompute_contributions(src_height, dst_height);
529
530    // Intermediate buffer: src_height rows × dst_width columns
531    let temp_size = src_height * dst_width * bytes_per_pixel;
532    let mut temp = vec![0u8; temp_size];
533
534    // Pass 1: Horizontal resampling (parallel when available)
535    #[cfg(feature = "parallel")]
536    {
537        use rayon::prelude::*;
538
539        let src_row_stride = src_width * bytes_per_pixel;
540        let temp_row_stride = dst_width * bytes_per_pixel;
541
542        temp.par_chunks_mut(temp_row_stride)
543            .enumerate()
544            .for_each(|(y, temp_row)| {
545                let src_start = y * src_row_stride;
546                let src_row = &data[src_start..src_start + src_row_stride];
547                resample_row_horizontal(src_row, temp_row, &h_contribs, bytes_per_pixel);
548            });
549    }
550
551    #[cfg(not(feature = "parallel"))]
552    {
553        let src_row_stride = src_width * bytes_per_pixel;
554        let temp_row_stride = dst_width * bytes_per_pixel;
555
556        for y in 0..src_height {
557            let src_start = y * src_row_stride;
558            let src_row = &data[src_start..src_start + src_row_stride];
559            let temp_start = y * temp_row_stride;
560            let temp_row = &mut temp[temp_start..temp_start + temp_row_stride];
561            resample_row_horizontal(src_row, temp_row, &h_contribs, bytes_per_pixel);
562        }
563    }
564
565    // Pass 2: Vertical resampling (parallel when available)
566    #[cfg(feature = "parallel")]
567    {
568        use rayon::prelude::*;
569
570        let dst_row_stride = dst_width * bytes_per_pixel;
571
572        output
573            .par_chunks_mut(dst_row_stride)
574            .enumerate()
575            .for_each(|(dst_y, dst_row)| {
576                resample_column_vertical(
577                    &temp,
578                    dst_row,
579                    &v_contribs[dst_y],
580                    dst_width,
581                    bytes_per_pixel,
582                );
583            });
584    }
585
586    #[cfg(not(feature = "parallel"))]
587    {
588        let dst_row_stride = dst_width * bytes_per_pixel;
589
590        for dst_y in 0..dst_height {
591            let dst_start = dst_y * dst_row_stride;
592            let dst_row = &mut output[dst_start..dst_start + dst_row_stride];
593            resample_column_vertical(
594                &temp,
595                dst_row,
596                &v_contribs[dst_y],
597                dst_width,
598                bytes_per_pixel,
599            );
600        }
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    /// Helper for tests: builds ResizeOptions from positional args
609    fn test_resize(
610        data: &[u8],
611        src_width: u32,
612        src_height: u32,
613        dst_width: u32,
614        dst_height: u32,
615        color_type: ColorType,
616        algorithm: ResizeAlgorithm,
617    ) -> Result<Vec<u8>> {
618        let options = ResizeOptions::builder(src_width, src_height)
619            .dst(dst_width, dst_height)
620            .color_type(color_type)
621            .algorithm(algorithm)
622            .build();
623        resize(data, &options)
624    }
625
626    /// Helper for tests: resize_into with positional args
627    #[allow(dead_code)]
628    #[allow(clippy::too_many_arguments)]
629    fn test_resize_into(
630        output: &mut Vec<u8>,
631        data: &[u8],
632        src_width: u32,
633        src_height: u32,
634        dst_width: u32,
635        dst_height: u32,
636        color_type: ColorType,
637        algorithm: ResizeAlgorithm,
638    ) -> Result<()> {
639        let options = ResizeOptions::builder(src_width, src_height)
640            .dst(dst_width, dst_height)
641            .color_type(color_type)
642            .algorithm(algorithm)
643            .build();
644        resize_into(output, data, &options)
645    }
646
647    #[test]
648    fn test_resize_nearest_basic() {
649        // 2x2 RGBA image -> 4x4
650        let pixels = vec![
651            255, 0, 0, 255, // Red
652            0, 255, 0, 255, // Green
653            0, 0, 255, 255, // Blue
654            255, 255, 0, 255, // Yellow
655        ];
656
657        let result = test_resize(
658            &pixels,
659            2,
660            2,
661            4,
662            4,
663            ColorType::Rgba,
664            ResizeAlgorithm::Nearest,
665        )
666        .unwrap();
667        assert_eq!(result.len(), 4 * 4 * 4);
668    }
669
670    #[test]
671    fn test_resize_bilinear_basic() {
672        // 2x2 RGB image -> 4x4
673        let pixels = vec![
674            255, 0, 0, // Red
675            0, 255, 0, // Green
676            0, 0, 255, // Blue
677            255, 255, 0, // Yellow
678        ];
679
680        let result = test_resize(
681            &pixels,
682            2,
683            2,
684            4,
685            4,
686            ColorType::Rgb,
687            ResizeAlgorithm::Bilinear,
688        )
689        .unwrap();
690        assert_eq!(result.len(), 4 * 4 * 3);
691    }
692
693    #[test]
694    fn test_resize_lanczos3_basic() {
695        // 4x4 grayscale image -> 2x2
696        let pixels = vec![0u8; 4 * 4];
697
698        let result = test_resize(
699            &pixels,
700            4,
701            4,
702            2,
703            2,
704            ColorType::Gray,
705            ResizeAlgorithm::Lanczos3,
706        )
707        .unwrap();
708        assert_eq!(result.len(), 2 * 2);
709    }
710
711    #[test]
712    fn test_resize_same_size() {
713        // Resize to same size should be near-identity
714        let pixels = vec![128u8; 8 * 8 * 4];
715
716        let result = test_resize(
717            &pixels,
718            8,
719            8,
720            8,
721            8,
722            ColorType::Rgba,
723            ResizeAlgorithm::Bilinear,
724        )
725        .unwrap();
726        assert_eq!(result.len(), pixels.len());
727    }
728
729    #[test]
730    fn test_resize_downscale() {
731        // 16x16 -> 4x4
732        let pixels: Vec<u8> = (0..16 * 16 * 3).map(|i| (i % 256) as u8).collect();
733
734        let result = test_resize(
735            &pixels,
736            16,
737            16,
738            4,
739            4,
740            ColorType::Rgb,
741            ResizeAlgorithm::Lanczos3,
742        )
743        .unwrap();
744        assert_eq!(result.len(), 4 * 4 * 3);
745    }
746
747    #[test]
748    fn test_resize_upscale() {
749        // 4x4 -> 16x16
750        let pixels: Vec<u8> = (0..4 * 4 * 3).map(|i| (i % 256) as u8).collect();
751
752        let result = test_resize(
753            &pixels,
754            4,
755            4,
756            16,
757            16,
758            ColorType::Rgb,
759            ResizeAlgorithm::Bilinear,
760        )
761        .unwrap();
762        assert_eq!(result.len(), 16 * 16 * 3);
763    }
764
765    #[test]
766    fn test_resize_non_square() {
767        // 8x4 -> 4x8
768        let pixels = vec![200u8; 8 * 4 * 4];
769
770        let result = test_resize(
771            &pixels,
772            8,
773            4,
774            4,
775            8,
776            ColorType::Rgba,
777            ResizeAlgorithm::Nearest,
778        )
779        .unwrap();
780        assert_eq!(result.len(), 4 * 8 * 4);
781    }
782
783    #[test]
784    fn test_resize_invalid_src_dimensions() {
785        let pixels = vec![0u8; 0];
786        let result = test_resize(
787            &pixels,
788            0,
789            10,
790            5,
791            5,
792            ColorType::Rgb,
793            ResizeAlgorithm::Nearest,
794        );
795        assert!(matches!(result, Err(Error::InvalidDimensions { .. })));
796    }
797
798    #[test]
799    fn test_resize_invalid_dst_dimensions() {
800        let pixels = vec![0u8; 10 * 10 * 3];
801        let result = test_resize(
802            &pixels,
803            10,
804            10,
805            0,
806            5,
807            ColorType::Rgb,
808            ResizeAlgorithm::Nearest,
809        );
810        assert!(matches!(result, Err(Error::InvalidDimensions { .. })));
811    }
812
813    #[test]
814    fn test_resize_invalid_data_length() {
815        let pixels = vec![0u8; 10]; // Wrong size for 10x10 RGB
816        let result = test_resize(
817            &pixels,
818            10,
819            10,
820            5,
821            5,
822            ColorType::Rgb,
823            ResizeAlgorithm::Nearest,
824        );
825        assert!(matches!(result, Err(Error::InvalidDataLength { .. })));
826    }
827
828    #[test]
829    fn test_resize_1x1_to_larger() {
830        // 1x1 -> 4x4 (edge case)
831        let pixels = vec![255, 128, 64, 255]; // RGBA
832
833        let result = test_resize(
834            &pixels,
835            1,
836            1,
837            4,
838            4,
839            ColorType::Rgba,
840            ResizeAlgorithm::Bilinear,
841        )
842        .unwrap();
843        assert_eq!(result.len(), 4 * 4 * 4);
844
845        // All pixels should be the same as the source (single color)
846        for i in 0..16 {
847            assert_eq!(result[i * 4], 255);
848            assert_eq!(result[i * 4 + 1], 128);
849            assert_eq!(result[i * 4 + 2], 64);
850            assert_eq!(result[i * 4 + 3], 255);
851        }
852    }
853
854    #[test]
855    fn test_resize_to_1x1() {
856        // 4x4 -> 1x1 (edge case)
857        let pixels = vec![128u8; 4 * 4 * 3];
858
859        let result = test_resize(
860            &pixels,
861            4,
862            4,
863            1,
864            1,
865            ColorType::Rgb,
866            ResizeAlgorithm::Lanczos3,
867        )
868        .unwrap();
869        assert_eq!(result.len(), 3);
870    }
871
872    #[test]
873    fn test_resize_gray_alpha() {
874        // Test GrayAlpha (2 bytes per pixel)
875        let pixels = vec![100, 200, 150, 250]; // 2x1 GrayAlpha
876
877        let result = test_resize(
878            &pixels,
879            2,
880            1,
881            4,
882            2,
883            ColorType::GrayAlpha,
884            ResizeAlgorithm::Bilinear,
885        )
886        .unwrap();
887        assert_eq!(result.len(), 4 * 2 * 2);
888    }
889
890    #[test]
891    fn test_resize_buffer_reuse() {
892        let mut output = Vec::with_capacity(1024);
893        let pixels = vec![128u8; 8 * 8 * 4];
894
895        let options1 = ResizeOptions::builder(8, 8)
896            .dst(4, 4)
897            .color_type(ColorType::Rgba)
898            .algorithm(ResizeAlgorithm::Nearest)
899            .build();
900        resize_into(&mut output, &pixels, &options1).unwrap();
901
902        let first_cap = output.capacity();
903        assert_eq!(output.len(), 4 * 4 * 4);
904
905        // Resize again with same output buffer
906        let options2 = ResizeOptions::builder(8, 8)
907            .dst(4, 4)
908            .color_type(ColorType::Rgba)
909            .algorithm(ResizeAlgorithm::Bilinear)
910            .build();
911        resize_into(&mut output, &pixels, &options2).unwrap();
912
913        // Capacity should be preserved (buffer reuse)
914        assert!(output.capacity() >= first_cap);
915    }
916
917    #[test]
918    fn test_resize_algorithm_default() {
919        // Default should be Bilinear
920        assert_eq!(ResizeAlgorithm::default(), ResizeAlgorithm::Bilinear);
921    }
922
923    #[test]
924    fn test_lanczos_kernel() {
925        // At x=0, kernel should be 1
926        assert!((lanczos_kernel(0.0, 3.0) - 1.0).abs() < 0.001);
927
928        // At x >= a, kernel should be 0
929        assert!(lanczos_kernel(3.0, 3.0).abs() < 0.001);
930        assert!(lanczos_kernel(4.0, 3.0).abs() < f32::EPSILON);
931
932        // Kernel should be symmetric
933        assert!((lanczos_kernel(1.5, 3.0) - lanczos_kernel(-1.5, 3.0)).abs() < 0.001);
934    }
935
936    #[test]
937    fn test_resize_large_dimension_error() {
938        let pixels = vec![0u8; 3];
939        let result = test_resize(
940            &pixels,
941            1,
942            1,
943            (1 << 25) as u32,
944            1,
945            ColorType::Rgb,
946            ResizeAlgorithm::Nearest,
947        );
948        assert!(matches!(result, Err(Error::ImageTooLarge { .. })));
949    }
950
951    #[test]
952    fn test_all_algorithms_produce_valid_output() {
953        let pixels: Vec<u8> = (0..32 * 32 * 4).map(|i| (i % 256) as u8).collect();
954
955        for algo in [
956            ResizeAlgorithm::Nearest,
957            ResizeAlgorithm::Bilinear,
958            ResizeAlgorithm::Lanczos3,
959        ] {
960            let result = test_resize(&pixels, 32, 32, 16, 16, ColorType::Rgba, algo).unwrap();
961            assert_eq!(result.len(), 16 * 16 * 4);
962
963            // All values should be valid u8
964            assert!(!result.is_empty());
965        }
966    }
967
968    // ============ Tests for separable filtering and precomputed contributions ============
969
970    #[test]
971    fn test_precompute_contributions_basic() {
972        // Test that contributions are computed correctly for simple cases
973        let contribs = precompute_contributions(100, 50); // 2x downscale
974
975        assert_eq!(contribs.len(), 50);
976
977        // Each contribution should have weights that sum to ~1.0
978        for contrib in &contribs {
979            let sum: f32 = contrib.weights.iter().sum();
980            assert!(
981                (sum - 1.0).abs() < 0.01,
982                "Weights should sum to 1.0, got {sum}"
983            );
984        }
985    }
986
987    #[test]
988    fn test_precompute_contributions_upscale() {
989        // Test upscaling contributions
990        let contribs = precompute_contributions(50, 100); // 2x upscale
991
992        assert_eq!(contribs.len(), 100);
993
994        for contrib in &contribs {
995            let sum: f32 = contrib.weights.iter().sum();
996            assert!(
997                (sum - 1.0).abs() < 0.01,
998                "Weights should sum to 1.0, got {sum}"
999            );
1000            // Upscaling should have reasonable kernel support (Lanczos3 has radius 3)
1001            assert!(
1002                contrib.weights.len() <= 8,
1003                "Kernel too large: {}",
1004                contrib.weights.len()
1005            );
1006        }
1007    }
1008
1009    #[test]
1010    fn test_precompute_contributions_same_size() {
1011        // Same size should produce identity-like contributions
1012        let contribs = precompute_contributions(100, 100);
1013
1014        assert_eq!(contribs.len(), 100);
1015
1016        for (i, contrib) in contribs.iter().enumerate() {
1017            // The primary weight should be very close to 1.0 at the center
1018            let max_weight = contrib.weights.iter().cloned().fold(0.0f32, f32::max);
1019            assert!(
1020                max_weight > 0.9,
1021                "Max weight at {i} should be near 1.0, got {max_weight}"
1022            );
1023        }
1024    }
1025
1026    #[test]
1027    fn test_lanczos3_large_downscale() {
1028        // Test significant downscaling (simulates the 5000x6000 -> smaller case)
1029        let src_w = 500;
1030        let src_h = 600;
1031        let dst_w = 100;
1032        let dst_h = 120;
1033
1034        let pixels: Vec<u8> = (0..(src_w * src_h * 4))
1035            .map(|i| ((i * 7) % 256) as u8)
1036            .collect();
1037
1038        let result = test_resize(
1039            &pixels,
1040            src_w as u32,
1041            src_h as u32,
1042            dst_w as u32,
1043            dst_h as u32,
1044            ColorType::Rgba,
1045            ResizeAlgorithm::Lanczos3,
1046        )
1047        .unwrap();
1048
1049        assert_eq!(result.len(), dst_w * dst_h * 4);
1050
1051        // Verify output is reasonable (not all zeros, not all 255s)
1052        let sum: u64 = result.iter().map(|&x| x as u64).sum();
1053        let avg = sum as f64 / result.len() as f64;
1054        assert!(
1055            avg > 10.0 && avg < 245.0,
1056            "Average pixel value {avg} is suspicious"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_lanczos3_preserves_solid_color() {
1062        // A solid color image should remain solid after resize
1063        let color = [100u8, 150, 200, 255];
1064        let pixels: Vec<u8> = (0..64 * 64).flat_map(|_| color).collect();
1065
1066        let result = test_resize(
1067            &pixels,
1068            64,
1069            64,
1070            32,
1071            32,
1072            ColorType::Rgba,
1073            ResizeAlgorithm::Lanczos3,
1074        )
1075        .unwrap();
1076
1077        // All pixels should be very close to the original color
1078        for i in 0..(32 * 32) {
1079            let idx = i * 4;
1080            for c in 0..4 {
1081                let diff = (result[idx + c] as i32 - color[c] as i32).abs();
1082                assert!(diff <= 1, "Color drift too large at pixel {i}, channel {c}");
1083            }
1084        }
1085    }
1086
1087    #[test]
1088    fn test_lanczos3_upscale_quality() {
1089        // Test that Lanczos3 produces smooth gradients on upscale
1090        // Create a simple 2x2 gradient
1091        let pixels = vec![
1092            0, 0, 0, 255, // Black
1093            255, 255, 255, 255, // White
1094            128, 128, 128, 255, // Gray
1095            64, 64, 64, 255, // Dark gray
1096        ];
1097
1098        let result = test_resize(
1099            &pixels,
1100            2,
1101            2,
1102            8,
1103            8,
1104            ColorType::Rgba,
1105            ResizeAlgorithm::Lanczos3,
1106        )
1107        .unwrap();
1108
1109        assert_eq!(result.len(), 8 * 8 * 4);
1110
1111        // Corner pixels should be close to original values
1112        // Top-left should be near black
1113        assert!(result[0] < 30, "Top-left should be near black");
1114        // Top-right (pixel 7) should be near white
1115        let tr_idx = 7 * 4;
1116        assert!(result[tr_idx] > 200, "Top-right should be near white");
1117    }
1118
1119    #[test]
1120    fn test_lanczos3_asymmetric_resize() {
1121        // Test non-uniform scaling (different x and y ratios)
1122        let pixels: Vec<u8> = (0..100 * 50 * 3).map(|i| (i % 256) as u8).collect();
1123
1124        let result = test_resize(
1125            &pixels,
1126            100,
1127            50,
1128            25,
1129            200, // 4x downscale in X, 4x upscale in Y
1130            ColorType::Rgb,
1131            ResizeAlgorithm::Lanczos3,
1132        )
1133        .unwrap();
1134
1135        assert_eq!(result.len(), 25 * 200 * 3);
1136    }
1137
1138    #[test]
1139    fn test_resample_row_horizontal_basic() {
1140        // Test the horizontal resampling helper directly
1141        let contribs = precompute_contributions(4, 2);
1142        let src_row = vec![100u8, 150, 200, 250]; // 4 gray pixels
1143        let mut dst_row = vec![0u8; 2];
1144
1145        resample_row_horizontal(&src_row, &mut dst_row, &contribs, 1);
1146
1147        // Output should be reasonable averages
1148        assert!(dst_row[0] > 50 && dst_row[0] < 200);
1149        assert!(dst_row[1] > 100 && dst_row[1] < 255);
1150    }
1151
1152    #[test]
1153    fn test_lanczos3_1x1_edge_case() {
1154        // 1x1 -> 10x10 with Lanczos3
1155        let pixels = vec![128, 64, 192, 255]; // Single RGBA pixel
1156
1157        let result = test_resize(
1158            &pixels,
1159            1,
1160            1,
1161            10,
1162            10,
1163            ColorType::Rgba,
1164            ResizeAlgorithm::Lanczos3,
1165        )
1166        .unwrap();
1167
1168        assert_eq!(result.len(), 10 * 10 * 4);
1169
1170        // All pixels should match the source (single color expansion)
1171        for i in 0..100 {
1172            let idx = i * 4;
1173            assert_eq!(result[idx], 128, "R mismatch at {i}");
1174            assert_eq!(result[idx + 1], 64, "G mismatch at {i}");
1175            assert_eq!(result[idx + 2], 192, "B mismatch at {i}");
1176            assert_eq!(result[idx + 3], 255, "A mismatch at {i}");
1177        }
1178    }
1179
1180    #[test]
1181    fn test_contribution_bounds_are_valid() {
1182        // Ensure precomputed contributions don't go out of bounds
1183        for (src, dst) in [(100, 10), (10, 100), (1000, 50), (50, 1000)] {
1184            let contribs = precompute_contributions(src, dst);
1185
1186            for (i, contrib) in contribs.iter().enumerate() {
1187                assert!(
1188                    contrib.start < src,
1189                    "Contribution {} start {} >= src {}",
1190                    i,
1191                    contrib.start,
1192                    src
1193                );
1194                let end = contrib.start + contrib.weights.len();
1195                assert!(end <= src, "Contribution {i} end {end} > src {src}");
1196            }
1197        }
1198    }
1199
1200    #[test]
1201    fn test_lanczos3_extreme_downscale() {
1202        // 1000x1000 -> 10x10 (100x downscale)
1203        let pixels: Vec<u8> = (0..1000 * 1000 * 3).map(|i| (i % 256) as u8).collect();
1204
1205        let result = test_resize(
1206            &pixels,
1207            1000,
1208            1000,
1209            10,
1210            10,
1211            ColorType::Rgb,
1212            ResizeAlgorithm::Lanczos3,
1213        )
1214        .unwrap();
1215
1216        assert_eq!(result.len(), 10 * 10 * 3);
1217    }
1218
1219    #[test]
1220    fn test_lanczos3_prime_dimensions() {
1221        // Test with prime number dimensions (edge case for algorithms)
1222        let pixels: Vec<u8> = (0..97 * 89 * 4).map(|i| (i % 256) as u8).collect();
1223
1224        let result = test_resize(
1225            &pixels,
1226            97,
1227            89,
1228            41,
1229            37,
1230            ColorType::Rgba,
1231            ResizeAlgorithm::Lanczos3,
1232        )
1233        .unwrap();
1234
1235        assert_eq!(result.len(), 41 * 37 * 4);
1236    }
1237}