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}