mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 20:36:02 +00:00
Merge pull request #7558 from dotCooCoo/fix/trace-downscale-alpha
Image Trace: scaling a large image down keeps the color of pixels next to clear ones
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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]];
|
||||
|
||||
Reference in New Issue
Block a user