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.
This commit is contained in:
dotCooCoo
2026-11-14 03:20:46 -08:00
parent edb228ac33
commit 9e0882199b
2 changed files with 78 additions and 4 deletions
+54 -4
View File
@@ -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<u8> {
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<u8> {
+24
View File
@@ -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]];