From 9e0882199b0d30c8d0a94128799f8b401309b16e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:10:01 -0800 Subject: [PATCH] Image Trace: scaling a large image down keeps the color of pixels next to clear ones An image over 64 megapixels is scaled down before tracing with a box filter that averaged red, green and blue without weighing them by alpha. A clear pixel (stored as transparent black) next to an opaque one pulled the scaled pixel's color toward black: an opaque red and a clear pixel gave a half-clear red at half its brightness. Each scaled pixel now takes its alpha as the mean of its block and its color from the block's colors weighted by their alpha. A wholly clear block keeps the plain mean of its colors. Every source pixel now counts in exactly one block, so the block edges differ slightly from before and an opaque image can also scale to slightly different values. --- crates/trace/src/raster.rs | 58 +++++++++++++++++++++++++++++++++++--- crates/trace/src/tests.rs | 24 ++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 5e198e6fb7..51312c685a 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -48,6 +48,54 @@ pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { (nw, nh) } +/// The `w × h` RGBA8 `rgba` reduced to `nw × nh` (at most `w × h`): each pixel covers a block of +/// whole source pixels, and every source pixel falls in exactly one block. A pixel's alpha is the +/// mean of its block's alphas, and its color is the block's colors weighted by their alpha, so a +/// clear pixel next to an opaque one does not darken or tint it. A wholly clear block (every alpha +/// 0) keeps the plain mean of its colors. +pub(crate) fn box_reduced(rgba: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec { + let span = |i: u32, n: u32, of: u32| { + let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX); + let start = at(i); + start..at(i + 1).max(start + 1) + }; + let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect(); + let mut out = Vec::with_capacity(nw as usize * nh as usize * 4); + // Per output column: the alpha-weighted color sums, the alpha sum and the plain color sums. + let mut sums = vec![[0u64; 7]; cols.len()]; + let row_len = w as usize * 4; + for ty in 0..nh { + let rows = span(ty, nh, h); + sums.fill([0; 7]); + for y in rows.clone() { + let Some(row) = rgba.get(y * row_len..(y + 1) * row_len) else { break }; + for (s, c) in sums.iter_mut().zip(&cols) { + let px = row.get(c.start * 4..c.end * 4).unwrap_or_default(); + for &[r, g, b, a] in px.as_chunks::<4>().0 { + let a64 = u64::from(a); + s[0] += u64::from(r) * a64; + s[1] += u64::from(g) * a64; + s[2] += u64::from(b) * a64; + s[3] += a64; + s[4] += u64::from(r); + s[5] += u64::from(g); + s[6] += u64::from(b); + } + } + } + for (s, c) in sums.iter().zip(&cols) { + let count = (rows.len() * c.len()).max(1) as u64; + let mean = |v: u64, n: u64| u8::try_from((v + n / 2) / n).unwrap_or(u8::MAX); + if s[3] == 0 { + out.extend_from_slice(&[mean(s[4], count), mean(s[5], count), mean(s[6], count), 0]); + } else { + out.extend_from_slice(&[mean(s[0], s[3]), mean(s[1], s[3]), mean(s[2], s[3]), mean(s[3], count)]); + } + } + } + out +} + /// An RGBA8 raster (row-major, top row first). #[derive(Clone, Debug, PartialEq)] pub struct Raster { @@ -150,12 +198,14 @@ impl Raster { let mut limits = image::Limits::default(); limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); reader.limits(limits); - let img = reader.decode().map_err(decode_err)?.into_rgba8(); + Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS)) + } + /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. + pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, MAX_PIXELS); + let (nw, nh) = fit_size(w, h, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - let small = image::imageops::thumbnail(&img, nw, nh); - Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) + Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec { diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 3b4e84989a..aa452347ef 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1085,6 +1085,30 @@ fn decode_to_fit_scales_an_oversized_image_down() { assert!(matches!(Raster::decode_to_fit(&png_header(9000, 9000)), Err(TraceError::Decode(_)))); } +/// Scaling an oversized image down weighs each color by its alpha: a clear pixel next to an +/// opaque one leaves the opaque color as it is and only lowers the alpha. +#[test] +fn scaling_down_weighs_color_by_alpha() { + use crate::raster::box_reduced; + const RED: [u8; 4] = [200, 30, 30, 255]; + // Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit` + // does: each scaled pixel covers a red and a clear one. + let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] }); + let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); + let fitted = Raster::fitted(&decoded, 2); + assert_eq!((fitted.width, fitted.height), (2, 1)); + assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat()); + // A partly clear blue pulls an opaque red toward it by its alpha. + let mixed = Raster::from_fn(2, 1, |x, _| if x == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 85] }); + assert_eq!(box_reduced(&mixed.rgba, 2, 1, 1, 1), [191, 0, 64, 170]); + // A wholly clear block stays clear, with the plain mean of its colors. + let clear = Raster::from_fn(2, 1, |x, _| [100 * x as u8, 0, 0, 0]); + assert_eq!(box_reduced(&clear.rgba, 2, 1, 1, 1), [50, 0, 0, 0]); + // A 3 → 2 reduction counts every source pixel once. + let ramp = Raster::from_fn(3, 3, |x, y| [(x * 60 + y * 10) as u8, 0, 0, 255]); + assert_eq!(box_reduced(&ramp.rgba, 3, 3, 2, 2), [0, 0, 0, 255, 90, 0, 0, 255, 15, 0, 0, 255, 105, 0, 0, 255]); +} + /// Four flat colours with anti-aliased (blended) edges between the quadrants. fn flat_quads() -> Raster { const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]];