resample.rs

11.0 kB · rust · 343 lines

1use super::errors::{value_error, Result};23/// The way a resampling weighs the source pixels it reads.4#[derive(Clone, Copy, Debug, PartialEq, Eq)]5pub enum Filter {6    /// The single nearest source pixel, palettes and hard edges kept.7    Nearest,8    /// The linear blend of the four source pixels around the target centre.9    Linear,10    /// The mean of the source box the target pixel covers.11    Box,12}1314/// The height of an equilateral triangle over its side, the squash a hex rendering wears.15pub const HEX_RATIO: f64 = 0.866_025_403_784_438_6;1617/// Returns the size a hex rendering wears, the named axis squashed by the triangle ratio.18///19/// ```20/// assert_eq!(mrlycore::resample::hex_size(100, 100, true), (86, 100));21/// assert_eq!(mrlycore::resample::hex_size(100, 100, false), (100, 86));22/// ```23pub fn hex_size(width: usize, height: usize, vertical: bool) -> (usize, usize) {24    let squash = |value: usize| ((value as f64 * HEX_RATIO) as usize).max(1);25    match vertical {26        true => (squash(width), height),27        false => (width, squash(height)),28    }29}3031/// Squashes rgba pixels to the hex aspect, returning the new width, height and pixels.32pub fn hex_fit(33    pixels: &[[u8; 4]],34    width: usize,35    height: usize,36    vertical: bool,37    filter: Filter,38) -> Result<(usize, usize, Vec<[u8; 4]>)> {39    let (out_w, out_h) = hex_size(width, height, vertical);40    let out = resample(pixels, width, height, out_w, out_h, filter)?;41    Ok((out_w, out_h, out))42}4344/// Resamples rgba pixels to a new size, or an error on an empty side or a length mismatch.45///46/// ```47/// let pixels = [[255, 0, 0, 255], [0, 0, 255, 255]];48/// let filter = mrlycore::resample::Filter::Nearest;49/// let wide = mrlycore::resample::resample(&pixels, 2, 1, 4, 1, filter).unwrap();50/// assert_eq!(wide, vec![pixels[0], pixels[0], pixels[1], pixels[1]]);51/// ```52pub fn resample(53    pixels: &[[u8; 4]],54    width: usize,55    height: usize,56    out_w: usize,57    out_h: usize,58    filter: Filter,59) -> Result<Vec<[u8; 4]>> {60    if width == 0 || height == 0 || out_w == 0 || out_h == 0 {61        return value_error("resample sides must be at least 1.");62    }63    if pixels.len() != width * height {64        return value_error("pixels length must equal width * height.");65    }66    if (out_w, out_h) == (width, height) {67        return Ok(pixels.to_vec());68    }69    Ok(match filter {70        Filter::Nearest => nearest(pixels, width, height, out_w, out_h),71        Filter::Linear => linear(pixels, width, height, out_w, out_h),72        Filter::Box => boxed(pixels, width, height, out_w, out_h),73    })74}7576/// Draws every source element as a scale by scale block, growing both sides by scale.77///78/// ```79/// let out = mrlycore::resample::block(&[1u8, 2], 2, 1, 2);80/// assert_eq!(out, vec![1, 1, 2, 2, 1, 1, 2, 2]);81/// ```82pub fn block<T: Copy>(src: &[T], width: usize, height: usize, scale: usize) -> Vec<T> {83    if scale == 1 {84        return src.to_vec();85    }86    let mut out = Vec::with_capacity(width * height * scale * scale);87    for y in 0..height {88        let start = out.len();89        for x in 0..width {90            for _ in 0..scale {91                out.push(src[y * width + x]);92            }93        }94        let row = start..out.len();95        for _ in 1..scale {96            out.extend_from_within(row.clone());97        }98    }99    out100}101102fn nearest(103    pixels: &[[u8; 4]],104    width: usize,105    height: usize,106    out_w: usize,107    out_h: usize,108) -> Vec<[u8; 4]> {109    let mut out = Vec::with_capacity(out_w * out_h);110    for y in 0..out_h {111        let row = y * height / out_h * width;112        for x in 0..out_w {113            out.push(pixels[row + x * width / out_w]);114        }115    }116    out117}118119fn linear(120    pixels: &[[u8; 4]],121    width: usize,122    height: usize,123    out_w: usize,124    out_h: usize,125) -> Vec<[u8; 4]> {126    let span = |target: usize, out: usize, source: usize| {127        let centre = (target as f64 + 0.5) * source as f64 / out as f64 - 0.5;128        let clamped = centre.clamp(0.0, (source - 1) as f64);129        let low = clamped.floor() as usize;130        (low, (low + 1).min(source - 1), clamped - low as f64)131    };132    let mut out = Vec::with_capacity(out_w * out_h);133    for y in 0..out_h {134        let (top, bottom, dy) = span(y, out_h, height);135        for x in 0..out_w {136            let (left, right, dx) = span(x, out_w, width);137            let corners = [138                pixels[top * width + left],139                pixels[top * width + right],140                pixels[bottom * width + left],141                pixels[bottom * width + right],142            ];143            let weights = [144                (1.0 - dx) * (1.0 - dy),145                dx * (1.0 - dy),146                (1.0 - dx) * dy,147                dx * dy,148            ];149            let mut blend = [0u8; 4];150            for (channel, slot) in blend.iter_mut().enumerate() {151                let sum: f64 = corners152                    .iter()153                    .zip(weights)154                    .map(|(c, w)| c[channel] as f64 * w)155                    .sum();156                *slot = sum.round().clamp(0.0, 255.0) as u8;157            }158            out.push(blend);159        }160    }161    out162}163164fn boxed(165    pixels: &[[u8; 4]],166    width: usize,167    height: usize,168    out_w: usize,169    out_h: usize,170) -> Vec<[u8; 4]> {171    let span = |target: usize, out: usize, source: usize| {172        let low = target * source / out;173        (low, ((target + 1) * source).div_ceil(out).max(low + 1))174    };175    let mut out = Vec::with_capacity(out_w * out_h);176    for y in 0..out_h {177        let (top, bottom) = span(y, out_h, height);178        for x in 0..out_w {179            let (left, right) = span(x, out_w, width);180            let count = ((bottom - top) * (right - left)) as u32;181            let mut sums = [0u32; 4];182            for row in top..bottom {183                for col in left..right {184                    let pixel = pixels[row * width + col];185                    for (slot, &value) in sums.iter_mut().zip(pixel.iter()) {186                        *slot += u32::from(value);187                    }188                }189            }190            let mut mean = [0u8; 4];191            for (slot, &sum) in mean.iter_mut().zip(sums.iter()) {192                *slot = ((sum + count / 2) / count) as u8;193            }194            out.push(mean);195        }196    }197    out198}199200#[cfg(test)]201mod tests {202    use super::*;203204    const FILTERS: [Filter; 3] = [Filter::Nearest, Filter::Linear, Filter::Box];205206    fn ramp(width: usize, height: usize) -> Vec<[u8; 4]> {207        (0..width * height)208            .map(|i| {209                let v = (i * 7 % 251) as u8;210                [v, 255 - v, v / 2, 255]211            })212            .collect()213    }214215    #[test]216    fn every_filter_keeps_the_same_size_untouched() {217        let pixels = ramp(5, 3);218        for filter in FILTERS {219            let out = resample(&pixels, 5, 3, 5, 3, filter).unwrap();220            assert_eq!(out, pixels, "{filter:?} moved an unchanged size");221        }222    }223224    #[test]225    fn every_filter_keeps_a_flat_field_flat() {226        let pixels = vec![[17, 34, 51, 255]; 64];227        for filter in FILTERS {228            for (w, h) in [(3, 3), (8, 8), (17, 5), (1, 1)] {229                let out = resample(&pixels, 8, 8, w, h, filter).unwrap();230                assert_eq!(out.len(), w * h);231                assert!(232                    out.iter().all(|&p| p == [17, 34, 51, 255]),233                    "{filter:?} smeared a flat field at {w}x{h}"234                );235            }236        }237    }238239    #[test]240    fn nearest_upscale_matches_block_replication() {241        let pixels = ramp(4, 3);242        let out = resample(&pixels, 4, 3, 12, 9, Filter::Nearest).unwrap();243        for y in 0..9 {244            for x in 0..12 {245                assert_eq!(out[y * 12 + x], pixels[(y / 3) * 4 + x / 3], "at {x},{y}");246            }247        }248    }249250    #[test]251    fn box_downscale_averages_the_block() {252        let pixels = vec![253            [0, 0, 0, 255],254            [10, 20, 30, 255],255            [100, 100, 100, 255],256            [200, 60, 40, 255],257        ];258        let out = resample(&pixels, 2, 2, 1, 1, Filter::Box).unwrap();259        assert_eq!(out, vec![[78, 45, 43, 255]]);260    }261262    #[test]263    fn box_halving_is_a_two_by_two_mean() {264        let pixels = ramp(8, 8);265        let out = resample(&pixels, 8, 8, 4, 4, Filter::Box).unwrap();266        for y in 0..4 {267            for x in 0..4 {268                let block = [269                    pixels[2 * y * 8 + 2 * x],270                    pixels[2 * y * 8 + 2 * x + 1],271                    pixels[(2 * y + 1) * 8 + 2 * x],272                    pixels[(2 * y + 1) * 8 + 2 * x + 1],273                ];274                let mean = (0..4)275                    .map(|c| ((block.iter().map(|p| p[c] as u32).sum::<u32>() + 2) / 4) as u8)276                    .collect::<Vec<u8>>();277                assert_eq!(out[y * 4 + x].to_vec(), mean, "at {x},{y}");278            }279        }280    }281282    #[test]283    fn linear_upscale_pins_the_corners() {284        let pixels = ramp(4, 4);285        let out = resample(&pixels, 4, 4, 16, 16, Filter::Linear).unwrap();286        assert_eq!(out[0], pixels[0]);287        assert_eq!(out[15], pixels[3]);288        assert_eq!(out[15 * 16], pixels[12]);289        assert_eq!(out[15 * 16 + 15], pixels[15]);290    }291292    #[test]293    fn linear_stays_between_its_neighbors() {294        let pixels = vec![[0, 0, 0, 255], [255, 255, 255, 255]];295        let out = resample(&pixels, 2, 1, 9, 1, Filter::Linear).unwrap();296        assert_eq!(out[0], [0, 0, 0, 255]);297        assert_eq!(out[8], [255, 255, 255, 255]);298        for pair in out.windows(2) {299            assert!(pair[1][0] >= pair[0][0], "linear ramp fell back");300        }301    }302303    #[test]304    fn resample_rejects_bad_sizes() {305        let pixels = ramp(2, 2);306        assert!(resample(&pixels, 2, 2, 0, 4, Filter::Nearest).is_err());307        assert!(resample(&pixels, 2, 2, 4, 0, Filter::Nearest).is_err());308        assert!(resample(&pixels, 0, 2, 4, 4, Filter::Nearest).is_err());309        assert!(resample(&pixels, 3, 2, 4, 4, Filter::Nearest).is_err());310    }311312    #[test]313    fn block_replicates_every_source_element() {314        let src: Vec<u8> = (0..6).collect();315        assert_eq!(block(&src, 3, 2, 1), src);316        let grown = block(&src, 3, 2, 3);317        assert_eq!(grown.len(), 54);318        for y in 0..6 {319            for x in 0..9 {320                assert_eq!(grown[y * 9 + x], src[(y / 3) * 3 + x / 3], "at {x},{y}");321            }322        }323    }324325    #[test]326    fn block_matches_a_nearest_upscale() {327        let pixels = ramp(4, 3);328        let out = resample(&pixels, 4, 3, 12, 9, Filter::Nearest).unwrap();329        assert_eq!(block(&pixels, 4, 3, 3), out);330    }331332    #[test]333    fn hex_fit_squashes_one_axis_only() {334        let pixels = ramp(20, 10);335        let (w, h, out) = hex_fit(&pixels, 20, 10, true, Filter::Box).unwrap();336        assert_eq!((w, h), (17, 10));337        assert_eq!(out.len(), 170);338        let (w, h, out) = hex_fit(&pixels, 20, 10, false, Filter::Nearest).unwrap();339        assert_eq!((w, h), (20, 8));340        assert_eq!(out.len(), 160);341        assert_eq!(hex_size(1, 1, true), (1, 1));342    }343}