mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 20:36:02 +00:00
Images: scaled-down images keep their color beside clear pixels
The image crate's filtered resize filters each channel on its own, which is correct only for premultiplied pixels, and its thumbnail averages each channel on its own too. Placed images, linked-image previews, MCP captures and previews, metafile bitmaps, PDF downsampling and library thumbnails passed straight RGBA to them, so the color under clear pixels (usually black) bled into the opaque pixels beside them and darkened cut-out edges. vectorcraft_doc::resample scales such images with each pixel's color weighted by its alpha: filtered resampling premultiplies the pixels in place and divides them back after, and averaging takes the area-weighted mean of the pixels each new one covers. Opaque and alpha-free images are scaled as before. PDF export also multiplies the inks of a CMYK image with alpha by that alpha before resampling them, and divides them by the resampled alpha after.
This commit is contained in:
@@ -61,6 +61,7 @@ pub mod range;
|
||||
pub mod rastersettings;
|
||||
mod reach;
|
||||
pub mod recolor;
|
||||
pub mod resample;
|
||||
pub mod selection;
|
||||
pub mod separated;
|
||||
pub mod setup;
|
||||
|
||||
+17
-1
@@ -229,7 +229,8 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
|
||||
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
|
||||
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
|
||||
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||||
let (w, h) = crate::resample::fit_size(img.width(), img.height(), PROXY_SIZE, PROXY_SIZE);
|
||||
let small = crate::resample::averaged(img, w, h);
|
||||
let mut png = Vec::new();
|
||||
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(png)
|
||||
@@ -728,4 +729,19 @@ mod tests {
|
||||
image(&mut d, "c", None);
|
||||
assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]);
|
||||
}
|
||||
|
||||
/// The preview of an image with clear pixels keeps the color of its opaque ones: the clear
|
||||
/// black beside them does not darken them, whichever way the preview is made.
|
||||
#[test]
|
||||
fn a_preview_keeps_the_color_beside_clear_pixels() {
|
||||
let mut full = vec![];
|
||||
image::RgbaImage::from_fn(1024, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })
|
||||
.write_to(&mut Cursor::new(&mut full), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
for blob in [ImageBlob::png(full.clone()).with_proxy(), ImageBlob::png(full.clone()).checked_with_proxy(true).unwrap()] {
|
||||
let p = image::load_from_memory(blob.proxy.as_ref().unwrap()).unwrap().into_rgba8();
|
||||
assert_eq!(p.dimensions(), (PROXY_SIZE, 1));
|
||||
assert!(p.pixels().all(|q| q.0 == [200, 30, 30, 128]), "{:?}", p.get_pixel(0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Scaling images whose pixels carry straight (not premultiplied) alpha.
|
||||
//!
|
||||
//! `image`'s filtered resampling filters each channel on its own, which is correct only for
|
||||
//! premultiplied pixels, and its thumbnail averages each channel on its own too, so on
|
||||
//! straight-alpha pixels the color under clear pixels bleeds into the opaque ones next to them
|
||||
//! (clear black darkens a cut-out's edge). [`resized`], [`fitted`] and [`averaged`] weight each
|
||||
//! pixel's color by its alpha while scaling, as the streaming shrink in the renderer does.
|
||||
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, RgbaImage};
|
||||
|
||||
/// How [`resized`] makes each new pixel from the old ones.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Resample {
|
||||
/// `image`'s filtered resampling with this filter.
|
||||
Filter(FilterType),
|
||||
/// The mean of the old pixels each new one covers ([`averaged`]).
|
||||
Average,
|
||||
}
|
||||
|
||||
/// `img` scaled to `w` × `h` pixels as `how` says, each pixel's color weighted by its alpha.
|
||||
/// Filtered or averaged, an image with clear or partly clear pixels comes back as 8-bit RGBA
|
||||
/// whatever its own pixel format, and a pixel that ends up wholly clear is transparent black. An
|
||||
/// image without clear pixels is scaled as `image` scales it, in its own pixel format.
|
||||
/// Nearest-neighbor picking leaves the pixels as they are, in their own format, and is not
|
||||
/// weighted. Filtered, the colors keep the precision of premultiplied 8-bit pixels, and an 8-bit
|
||||
/// RGBA image is premultiplied in place.
|
||||
pub fn resized(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage {
|
||||
if (w, h) == (img.width(), img.height()) {
|
||||
return img;
|
||||
}
|
||||
let f = match how {
|
||||
Resample::Average => return averaged(&img, w, h),
|
||||
Resample::Filter(f) => f,
|
||||
};
|
||||
if f == FilterType::Nearest || opaque(&img) {
|
||||
return img.resize_exact(w, h, f);
|
||||
}
|
||||
let mut rgba = img.into_rgba8();
|
||||
for p in rgba.pixels_mut() {
|
||||
let [r, g, b, a] = &mut p.0;
|
||||
for c in [r, g, b] {
|
||||
*c = times_alpha(*c, *a);
|
||||
}
|
||||
}
|
||||
let mut out = image::imageops::resize(&rgba, w, h, f);
|
||||
for p in out.pixels_mut() {
|
||||
let [r, g, b, a] = &mut p.0;
|
||||
for c in [r, g, b] {
|
||||
*c = over_alpha(*c, *a);
|
||||
}
|
||||
}
|
||||
DynamicImage::ImageRgba8(out)
|
||||
}
|
||||
|
||||
/// [`resized`] to the largest size that fits `w` × `h` pixels and keeps `img`'s proportions, the
|
||||
/// size `image`'s own `resize` and `thumbnail` pick.
|
||||
pub fn fitted(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage {
|
||||
let (nw, nh) = fit_size(img.width(), img.height(), w, h);
|
||||
resized(img, nw, nh, how)
|
||||
}
|
||||
|
||||
/// `img` scaled to `w` × `h` pixels, each new pixel the mean of the old pixels it covers, each old
|
||||
/// pixel counted by the area of it covered and its color weighted by its alpha. `img` is read one
|
||||
/// row at a time, so besides the result this takes one row of 8-bit RGBA at most. An image
|
||||
/// without clear pixels is scaled as `image`'s thumbnail scales it.
|
||||
pub fn averaged(img: &DynamicImage, w: u32, h: u32) -> DynamicImage {
|
||||
let (sw, sh) = (img.width(), img.height());
|
||||
if opaque(img) || w == 0 || h == 0 || sw == 0 || sh == 0 {
|
||||
return img.thumbnail_exact(w, h);
|
||||
}
|
||||
// Per new pixel of the row being made: red, green and blue times alpha, alpha (all times the
|
||||
// area covered), and the area covered.
|
||||
let mut sums = vec![([0f64; 4], 0f64); w as usize];
|
||||
let mut out = Vec::with_capacity(w as usize * h as usize * 4);
|
||||
for ty in 0..h {
|
||||
for (y, wy) in covered(ty, sh, h) {
|
||||
let row = rgba_row(img, y as u32);
|
||||
for ((s, area), tx) in sums.iter_mut().zip(0..w) {
|
||||
for (x, wx) in covered(tx, sw, w) {
|
||||
let Some(p) = row.get(x * 4..x * 4 + 4) else { continue };
|
||||
let k = wx * wy;
|
||||
let a = f64::from(p[3]) * k;
|
||||
s[0] += f64::from(p[0]) * a;
|
||||
s[1] += f64::from(p[1]) * a;
|
||||
s[2] += f64::from(p[2]) * a;
|
||||
s[3] += a;
|
||||
*area += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
let byte = |v: f64| v.round().clamp(0.0, 255.0) as u8;
|
||||
for (s, area) in &mut sums {
|
||||
out.extend(if s[3] <= 0.0 { [0; 4] } else { [byte(s[0] / s[3]), byte(s[1] / s[3]), byte(s[2] / s[3]), byte(s[3] / *area)] });
|
||||
(*s, *area) = ([0.0; 4], 0.0);
|
||||
}
|
||||
}
|
||||
RgbaImage::from_raw(w, h, out).map_or_else(|| img.thumbnail_exact(w, h), DynamicImage::ImageRgba8)
|
||||
}
|
||||
|
||||
/// Row `y` of `img` as 8-bit RGBA: borrowed from an 8-bit RGBA image, converted from others.
|
||||
fn rgba_row(img: &DynamicImage, y: u32) -> std::borrow::Cow<'_, [u8]> {
|
||||
let n = img.width() as usize * 4;
|
||||
match img {
|
||||
DynamicImage::ImageRgba8(b) => std::borrow::Cow::Borrowed(b.as_raw().get(y as usize * n..(y as usize + 1) * n).unwrap_or_default()),
|
||||
other => std::borrow::Cow::Owned(other.crop_imm(0, y, other.width(), 1).into_rgba8().into_raw()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The old pixels that new pixel `t` of `tn` covers along a side of `n` old pixels, each with the
|
||||
/// length of it covered (in old pixels), worked out as they are read so that nothing is stored.
|
||||
fn covered(t: u32, n: u32, tn: u32) -> impl Iterator<Item = (usize, f64)> {
|
||||
let scale = f64::from(n) / f64::from(tn);
|
||||
let (a, b) = (f64::from(t) * scale, (f64::from(t) + 1.0) * scale);
|
||||
let (first, end) = (a.floor() as u32, (b.ceil() as u32).min(n));
|
||||
(first..end.max(first + 1).min(n)).filter_map(move |i| {
|
||||
let len = b.min(f64::from(i) + 1.0) - a.max(f64::from(i));
|
||||
(len > 0.0).then_some((i as usize, len))
|
||||
})
|
||||
}
|
||||
|
||||
/// Each 4-channel pixel of `px` with all four channels multiplied by its value in `alpha` (one
|
||||
/// sample a pixel, 0 to 255): ink amounts or colors made ready for scaling beside a separate alpha
|
||||
/// channel. Pixels past the end of `alpha` are left as they are.
|
||||
pub fn premultiply(px: &mut [u8], alpha: &[u8]) {
|
||||
for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) {
|
||||
for c in p {
|
||||
*c = times_alpha(*c, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`premultiply`] undone after scaling: each 4-channel pixel of `px` divided by its value in the
|
||||
/// scaled `alpha`, and 0 where that is 0.
|
||||
pub fn unpremultiply(px: &mut [u8], alpha: &[u8]) {
|
||||
for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) {
|
||||
for c in p {
|
||||
*c = over_alpha(*c, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `img` have no alpha channel, or is every pixel of it opaque?
|
||||
fn opaque(img: &DynamicImage) -> bool {
|
||||
match img {
|
||||
DynamicImage::ImageRgba8(b) => b.pixels().all(|p| p[3] == u8::MAX),
|
||||
DynamicImage::ImageLumaA8(b) => b.pixels().all(|p| p[1] == u8::MAX),
|
||||
DynamicImage::ImageRgba16(b) => b.pixels().all(|p| p[3] == u16::MAX),
|
||||
DynamicImage::ImageLumaA16(b) => b.pixels().all(|p| p[1] == u16::MAX),
|
||||
DynamicImage::ImageRgba32F(b) => b.pixels().all(|p| p[3] >= 1.0),
|
||||
other => !other.color().has_alpha(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel `c` times alpha `a`, both 0 to 255.
|
||||
fn times_alpha(c: u8, a: u8) -> u8 {
|
||||
((u16::from(c) * u16::from(a) + 127) / 255) as u8
|
||||
}
|
||||
|
||||
/// Premultiplied channel `c` divided by alpha `a` (0 when `a` is 0).
|
||||
fn over_alpha(c: u8, a: u8) -> u8 {
|
||||
if a == 0 {
|
||||
return 0;
|
||||
}
|
||||
let a = u32::from(a);
|
||||
((u32::from(c) * 255 + a / 2) / a).min(255) as u8
|
||||
}
|
||||
|
||||
/// The largest size of at most `nw` × `nh` with the proportions of `w` × `h`, at least a pixel a
|
||||
/// side.
|
||||
pub fn fit_size(w: u32, h: u32, nw: u32, nh: u32) -> (u32, u32) {
|
||||
if w == 0 || h == 0 {
|
||||
return (nw.max(1), nh.max(1));
|
||||
}
|
||||
let ratio = (f64::from(nw) / f64::from(w)).min(f64::from(nh) / f64::from(h));
|
||||
let side = |n: u32| ((f64::from(n) * ratio).round() as u32).max(1);
|
||||
(side(w), side(h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 4 × 2 image whose left half is opaque red and whose right half is clear black.
|
||||
fn half_clear() -> DynamicImage {
|
||||
DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }))
|
||||
}
|
||||
|
||||
/// Is the color of `p`, premultiplied by its alpha, within a step of `rgb` premultiplied by the
|
||||
/// same alpha (the precision of premultiplied 8-bit pixels)?
|
||||
fn close(p: [u8; 4], rgb: [u8; 3]) -> bool {
|
||||
p[..3].iter().zip(rgb).all(|(&c, want)| times_alpha(c, p[3]).abs_diff(times_alpha(want, p[3])) <= 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averaging_keeps_the_color_of_the_opaque_pixels() {
|
||||
let p = resized(half_clear(), 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0;
|
||||
assert!(close(p, [200, 30, 30]) && p[3].abs_diff(128) <= 1, "{p:?}");
|
||||
}
|
||||
|
||||
/// Three pixels averaged into two: each new pixel takes one old pixel whole and half of the
|
||||
/// clear one in the middle.
|
||||
#[test]
|
||||
fn averaging_counts_the_part_of_a_pixel_covered() {
|
||||
let img = RgbaImage::from_fn(3, 1, |x, _| if x == 1 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([200, 30, 30, 255]) });
|
||||
let out = averaged(&DynamicImage::ImageRgba8(img), 2, 1).into_rgba8();
|
||||
assert_eq!(out.into_raw(), [[200, 30, 30, 170], [200, 30, 30, 170]].concat());
|
||||
// Gray with alpha is read the same way.
|
||||
let gray = image::GrayAlphaImage::from_fn(3, 1, |x, _| image::LumaA(if x == 1 { [0, 0] } else { [200, 255] }));
|
||||
let out = averaged(&DynamicImage::ImageLumaA8(gray), 2, 1).into_rgba8();
|
||||
assert_eq!(out.into_raw(), [[200, 200, 200, 170], [200, 200, 200, 170]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_keeps_the_color_of_the_opaque_pixels() {
|
||||
for f in [FilterType::Triangle, FilterType::CatmullRom, FilterType::Lanczos3] {
|
||||
let out = resized(half_clear(), 2, 1, Resample::Filter(f)).into_rgba8();
|
||||
for x in 0..2 {
|
||||
let p = out.get_pixel(x, 0).0;
|
||||
assert!(p[3] == 0 || close(p, [200, 30, 30]), "{f:?} pixel {x}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_keeps_proportions() {
|
||||
let img = DynamicImage::ImageRgba8(RgbaImage::new(400, 100));
|
||||
let out = fitted(img, 64, 64, Resample::Average);
|
||||
assert_eq!((out.width(), out.height()), (64, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_and_alpha_free_images_scale_as_image_scales_them() {
|
||||
let rgb = DynamicImage::ImageRgb8(image::RgbImage::from_fn(5, 3, |x, y| image::Rgb([x as u8 * 40, y as u8 * 60, 7])));
|
||||
let ours = resized(rgb.clone(), 2, 2, Resample::Filter(FilterType::Triangle));
|
||||
assert_eq!(ours, rgb.resize_exact(2, 2, FilterType::Triangle));
|
||||
let opaque = DynamicImage::ImageRgba8(rgb.to_rgba8());
|
||||
assert_eq!(resized(opaque.clone(), 2, 2, Resample::Average), opaque.thumbnail_exact(2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wholly_clear_result_is_transparent_black() {
|
||||
let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, image::Rgba([90, 90, 90, 0])));
|
||||
assert_eq!(resized(img, 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0, [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premultiply_and_unpremultiply_scale_beside_a_separate_alpha() {
|
||||
// Two pixels of inks, the second clear: averaged, the first one's inks remain.
|
||||
let mut px = vec![40, 80, 120, 160, 0, 0, 0, 0];
|
||||
premultiply(&mut px, &[255, 0]);
|
||||
let img = RgbaImage::from_raw(2, 1, px).unwrap();
|
||||
let mut out = image::imageops::thumbnail(&img, 1, 1).into_raw();
|
||||
let alpha = image::imageops::thumbnail(&image::GrayImage::from_raw(2, 1, vec![255, 0]).unwrap(), 1, 1).into_raw();
|
||||
unpremultiply(&mut out, &alpha);
|
||||
let want = [40, 80, 120, 160];
|
||||
assert!(out.iter().zip(want).all(|(&c, w)| times_alpha(c, alpha[0]).abs_diff(times_alpha(w, alpha[0])) <= 1), "{out:?} {alpha:?}");
|
||||
}
|
||||
}
|
||||
@@ -490,6 +490,43 @@ fn trace_and_style_prompt_steps_run_in_order() {
|
||||
assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}");
|
||||
}
|
||||
|
||||
/// A window capture and an artboard preview scaled down keep the color of opaque pixels beside
|
||||
/// clear ones: the clear black beside them does not darken them.
|
||||
#[test]
|
||||
fn scaled_down_pictures_keep_the_color_beside_clear_pixels() {
|
||||
struct Striped;
|
||||
impl Backend for Striped {
|
||||
fn call(&mut self, method: &str, _: Value) -> Result<Value, String> {
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })
|
||||
.write_to(&mut png, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let png = vectorcraft_format::base64_encode(png.get_ref());
|
||||
Ok(match method {
|
||||
"document.inspect" => json!({"artboards": [{"width": 64, "height": 4}]}),
|
||||
_ => json!({"pngBase64": png, "width": 64, "height": 4}),
|
||||
})
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"test".into()
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = Server::new(Box::new(Striped));
|
||||
let shots = [("screenshot", json!({"window": true, "max_side": 16})), ("render_preview", json!({"max_side": 16}))];
|
||||
for (id, (tool, args)) in (1..).zip(shots) {
|
||||
let r = call(&mut s, id, tool, args);
|
||||
let data =
|
||||
r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("{tool}: {r}"))["data"].as_str().unwrap();
|
||||
let img = image::load_from_memory(&vectorcraft_format::base64_decode(data).unwrap()).unwrap().into_rgba8();
|
||||
assert_eq!(img.dimensions(), (16, 1), "{tool}");
|
||||
assert!(img.pixels().all(|p| p.0 == [200, 30, 30, 128]), "{tool}: {:?}", img.get_pixel(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// `screenshot` and `render_preview` with `traceViews: true` draw an Image Trace object in the
|
||||
/// view set with imageTrace.setView, as the app's window does; without it they draw the tracing
|
||||
/// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White.
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::handlers::{
|
||||
select_ids, transform,
|
||||
};
|
||||
use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, json, writable_format};
|
||||
use vectorcraft_doc::resample::{Resample, fitted};
|
||||
|
||||
/// The `menu` argument of the command listings.
|
||||
pub(super) const MENU_FILTER: &str =
|
||||
@@ -445,7 +446,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
|
||||
// The render was asked for at most 4096 a side.
|
||||
let img = super::handlers::decode_png(&png, 8192).map_err(|e| format!("read preview: {e}"))?;
|
||||
let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img };
|
||||
let img = if img.width() > max || img.height() > max { fitted(img, max, max, Resample::Average) } else { img };
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?;
|
||||
Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()})))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::dispatch::trace_views;
|
||||
use super::{Backend, Map, ToolResult, Value, json};
|
||||
use vectorcraft_doc::resample::{Resample, fitted};
|
||||
|
||||
// ---------- implementation ----------
|
||||
|
||||
@@ -521,7 +522,7 @@ fn fit_capture(png: Vec<u8>, max: Option<u32>, info: &mut Value) -> Result<Vec<u
|
||||
info["height"] = json!(img.height());
|
||||
return Ok(png);
|
||||
}
|
||||
let img = img.thumbnail(max, max);
|
||||
let img = fitted(img, max, max, Resample::Average);
|
||||
info["width"] = json!(img.width());
|
||||
info["height"] = json!(img.height());
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use vectorcraft_doc::resample::{Resample, resized};
|
||||
|
||||
use crate::bytes::Reader;
|
||||
|
||||
/// Most pixels along a side of an image the writers store (larger ones are scaled down).
|
||||
@@ -42,7 +44,8 @@ impl Rgba {
|
||||
let img = if k > 1.0 {
|
||||
let nw = ((f64::from(w) / k).floor() as u32).max(1);
|
||||
let nh = ((f64::from(h) / k).floor() as u32).max(1);
|
||||
image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle)
|
||||
let how = Resample::Filter(image::imageops::FilterType::Triangle);
|
||||
resized(image::DynamicImage::ImageRgba8(img), nw, nh, how).into_rgba8()
|
||||
} else {
|
||||
img
|
||||
};
|
||||
@@ -447,4 +450,18 @@ mod tests {
|
||||
let f = Rgba::fitted(img);
|
||||
assert_eq!((f.width, f.height), (MAX_SIDE, 2));
|
||||
}
|
||||
|
||||
/// A large image scaled to fit keeps the color of its opaque pixels beside clear ones: the
|
||||
/// clear black beside them does not darken them.
|
||||
#[test]
|
||||
fn large_images_keep_their_color_beside_clear_pixels() {
|
||||
let img =
|
||||
image::RgbaImage::from_fn(MAX_SIDE * 2, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) });
|
||||
let f = Rgba::fitted(img);
|
||||
assert_eq!((f.width, f.height), (MAX_SIDE, 2));
|
||||
let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8;
|
||||
for p in f.pixels.as_chunks::<4>().0 {
|
||||
assert!(p[3] > 0 && [200, 30, 30].iter().zip(p).all(|(&want, &c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-17
@@ -21,7 +21,8 @@ use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageRea
|
||||
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
|
||||
|
||||
use vectorcraft_doc::ImageBlob;
|
||||
use vectorcraft_doc::cmyk::jpeg_components;
|
||||
use vectorcraft_doc::cmyk::{Inks, jpeg_components};
|
||||
use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply};
|
||||
|
||||
use crate::output::{CmykPixels, Pixels};
|
||||
use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec};
|
||||
@@ -225,15 +226,29 @@ fn jpeg_quality(r: &Rule, jpeg: bool) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` resampled to `w` × `h` pixels as `r` says.
|
||||
/// How `r` resamples an image.
|
||||
fn resampling(r: &Rule) -> Resample {
|
||||
match r.downsample {
|
||||
Downsample::Average => Resample::Average,
|
||||
Downsample::Subsample => Resample::Filter(FilterType::Nearest),
|
||||
Downsample::Bicubic | Downsample::None => Resample::Filter(FilterType::CatmullRom),
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` resampled to `w` × `h` pixels as `r` says, the colors of an image with alpha weighted by
|
||||
/// it ([`resized`]).
|
||||
fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage {
|
||||
resized(img, w, h, resampling(r))
|
||||
}
|
||||
|
||||
/// The four channels of `img` resampled to `w` × `h` pixels as `r` says, each on its own.
|
||||
fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage {
|
||||
if (w, h) == (img.width(), img.height()) {
|
||||
return img;
|
||||
}
|
||||
match r.downsample {
|
||||
Downsample::Average => img.thumbnail_exact(w, h),
|
||||
Downsample::Subsample => img.resize_exact(w, h, FilterType::Nearest),
|
||||
Downsample::Bicubic | Downsample::None => img.resize_exact(w, h, FilterType::CatmullRom),
|
||||
match resampling(r) {
|
||||
Resample::Average => img.thumbnail_exact(w, h),
|
||||
Resample::Filter(f) => img.resize_exact(w, h, f),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,17 +358,10 @@ pub(crate) fn recode_cmyk(
|
||||
let Some(inks) = blob.cmyk() else {
|
||||
return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) };
|
||||
};
|
||||
let dims = (inks.width, inks.height);
|
||||
let (width, height) = sized(&rule, dims, size);
|
||||
let alpha = match alpha.map(|a| image::GrayImage::from_raw(inks.width, inks.height, a)) {
|
||||
Some(Some(a)) => Some(resample(DynamicImage::ImageLuma8(a), &rule, (width, height)).into_luma8().into_raw()),
|
||||
Some(None) => return Recoded { image: None, warning: None },
|
||||
None => None,
|
||||
};
|
||||
// The four inks resample as the four channels of an RGBA image do.
|
||||
let inks = match image::RgbaImage::from_raw(inks.width, inks.height, inks.data) {
|
||||
Some(img) => resample(DynamicImage::ImageRgba8(img), &rule, (width, height)).into_rgba8().into_raw(),
|
||||
None => return Recoded { image: None, warning: Some(CMYK_RGB) },
|
||||
let (width, height) = sized(&rule, (inks.width, inks.height), size);
|
||||
let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) {
|
||||
Ok(r) => r,
|
||||
Err(warning) => return Recoded { image: None, warning },
|
||||
};
|
||||
let (samples, space) = match convert {
|
||||
Some(p) => p.convert(&inks),
|
||||
@@ -365,6 +373,36 @@ pub(crate) fn recode_cmyk(
|
||||
Recoded { image, warning: rule.instead }
|
||||
}
|
||||
|
||||
/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any.
|
||||
type InksAndAlpha = (Vec<u8>, Option<Vec<u8>>);
|
||||
|
||||
/// Ink amounts `inks` and their `alpha` (one sample a pixel) resampled to `to` as `r` says. The
|
||||
/// inks under clear pixels don't count where neighbors blend: they are multiplied by the alpha
|
||||
/// before resampling and divided by the resampled alpha after. `Err` with the warning when they
|
||||
/// can't be read.
|
||||
fn resampled_inks(inks: Inks, alpha: Option<Vec<u8>>, r: &Rule, to: (u32, u32)) -> Result<InksAndAlpha, Option<&'static str>> {
|
||||
let dims = (inks.width, inks.height);
|
||||
let weighted = to != dims && resampling(r) != Resample::Filter(FilterType::Nearest);
|
||||
let mut data = inks.data;
|
||||
let alpha = match alpha.map(|a| image::GrayImage::from_raw(dims.0, dims.1, a)) {
|
||||
Some(Some(a)) => {
|
||||
if weighted {
|
||||
premultiply(&mut data, a.as_raw());
|
||||
}
|
||||
Some(resample(DynamicImage::ImageLuma8(a), r, to).into_luma8().into_raw())
|
||||
}
|
||||
Some(None) => return Err(None),
|
||||
None => None,
|
||||
};
|
||||
// The four inks resample as the four channels of an RGBA image do, each on its own.
|
||||
let img = image::RgbaImage::from_raw(dims.0, dims.1, data).ok_or(Some(CMYK_RGB))?;
|
||||
let mut inks = resample_channels(DynamicImage::ImageRgba8(img), r, to).into_rgba8().into_raw();
|
||||
if let Some(a) = alpha.as_deref().filter(|_| weighted) {
|
||||
unpremultiply(&mut inks, a);
|
||||
}
|
||||
Ok((inks, alpha))
|
||||
}
|
||||
|
||||
/// The most one image read here may allocate (bytes), as the image reader allows by default.
|
||||
const MAX_DECODE: u64 = 512 << 20;
|
||||
|
||||
@@ -422,3 +460,26 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual
|
||||
}
|
||||
Image::from_jpeg(out.into(), interpolate).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The inks of a CMYK image with alpha, resampled, keep the amounts of the opaque pixels: the
|
||||
/// inks under the clear pixels beside them don't thin them out.
|
||||
#[test]
|
||||
fn resampled_inks_keep_the_amounts_beside_clear_pixels() {
|
||||
let data = [[40, 80, 120, 160], [0, 0, 0, 0]].repeat(8).concat();
|
||||
let alpha = [255, 0].repeat(8);
|
||||
for method in ["average", "bicubic"] {
|
||||
let c: CompressionSettings = serde_json::from_value(serde_json::json!({"color": {"downsample": method}})).unwrap();
|
||||
let inks = Inks::new(16, 1, data.clone()).unwrap();
|
||||
let (inks, alpha) = resampled_inks(inks, Some(alpha.clone()), &rule(&c, Kind::Color), (4, 1)).unwrap();
|
||||
let alpha = alpha.unwrap();
|
||||
let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8;
|
||||
for (p, &a) in inks.as_chunks::<4>().0.iter().zip(&alpha) {
|
||||
assert!(a > 0 && p.iter().zip([40, 80, 120, 160]).all(|(&c, want)| m(c, a).abs_diff(m(want, a)) <= 1), "{method}: {p:?} at {a}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,38 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() {
|
||||
assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi");
|
||||
}
|
||||
|
||||
/// An image with clear pixels, resampled, keeps the color of its opaque pixels: the clear black
|
||||
/// beside them does not darken them.
|
||||
#[test]
|
||||
fn resampled_images_keep_their_color_beside_clear_pixels() {
|
||||
let striped = image::RgbaImage::from_fn(600, 600, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) });
|
||||
let d = doc(encoded(striped.into(), ImageFormat::Png), "image/png");
|
||||
for method in ["average", "bicubic"] {
|
||||
let mut settings: PdfSettings =
|
||||
serde_json::from_value(json!({"compression": {"color": {"downsample": method, "ppi": 150, "abovePpi": 225, "compression": "zip"}}}))
|
||||
.unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let rgb = file
|
||||
.objects()
|
||||
.into_iter()
|
||||
.find_map(|o| match o {
|
||||
Object::Stream(s)
|
||||
if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") && s.dict().contains_key(b"SMask") =>
|
||||
{
|
||||
Some(s.decoded().unwrap().into_owned())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{method}: a color image with a soft mask"));
|
||||
assert_eq!(rgb.len(), 150 * 150 * 3, "{method}");
|
||||
for p in rgb.as_chunks::<3>().0 {
|
||||
assert!(p[0].abs_diff(200) <= 3 && p[1].abs_diff(30) <= 3 && p[2].abs_diff(30) <= 3, "{method}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height of each image XObject in `pdf`.
|
||||
fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> {
|
||||
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Paint conversion (solid, gradients, patterns) and image decoding.
|
||||
|
||||
use vectorcraft_color::{GradientKind, Paint};
|
||||
use vectorcraft_doc::resample::{Resample, resized};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko::{self, ColorStop};
|
||||
@@ -446,7 +447,7 @@ fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage {
|
||||
}
|
||||
let s = max as f64 / w.max(h) as f64;
|
||||
let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max));
|
||||
img.resize_exact(nw, nh, image::imageops::FilterType::Triangle)
|
||||
resized(img, nw, nh, Resample::Filter(image::imageops::FilterType::Triangle))
|
||||
}
|
||||
|
||||
/// [`decode_pixmap_or_why`]'s pixels.
|
||||
@@ -504,7 +505,7 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixm
|
||||
}
|
||||
};
|
||||
let img = if img.width() > w as u32 || img.height() > h as u32 {
|
||||
img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle)
|
||||
resized(img, w as u32, h as u32, Resample::Filter(image::imageops::FilterType::Triangle))
|
||||
} else {
|
||||
img
|
||||
};
|
||||
@@ -587,4 +588,35 @@ mod tests {
|
||||
assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC + 1), Err(Undecoded::NoRoom));
|
||||
assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(()));
|
||||
}
|
||||
|
||||
/// Opaque red beside clear black, every other column of a `w` × 1 image.
|
||||
fn striped(w: u32) -> image::RgbaImage {
|
||||
image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })
|
||||
}
|
||||
|
||||
/// Does each pixel of `pm` hold red at its own coverage (premultiplied, within a step)?
|
||||
fn red_at_its_coverage(pm: &vello_cpu::Pixmap) -> bool {
|
||||
let m = |c: u8, a: u8| ((u16::from(c) * u16::from(a) + 127) / 255) as u8;
|
||||
pm.data().iter().all(|p| p.r.abs_diff(m(200, p.a)) <= 1 && p.g.abs_diff(m(30, p.a)) <= 1 && p.b.abs_diff(m(30, p.a)) <= 1)
|
||||
}
|
||||
|
||||
/// A placed image drawn smaller keeps the color of its opaque pixels at its cut-out edges: the
|
||||
/// clear pixels beside them do not darken them.
|
||||
#[test]
|
||||
fn an_image_drawn_smaller_keeps_its_color_beside_clear_pixels() {
|
||||
let mut png = Vec::new();
|
||||
striped(8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let pm = decode_pixmap_fit(&png, 2, 1).unwrap();
|
||||
assert_eq!((pm.width(), pm.height()), (2, 1));
|
||||
assert!(pm.data().iter().all(|p| p.a > 0));
|
||||
assert!(red_at_its_coverage(&pm), "{:?}", pm.data());
|
||||
}
|
||||
|
||||
/// An image wider than a pixmap may be is shrunk to fit with its colors weighted by alpha the same way.
|
||||
#[test]
|
||||
fn an_image_shrunk_to_fit_a_pixmap_keeps_its_color_beside_clear_pixels() {
|
||||
let pm = decoded_pixmap(image::DynamicImage::ImageRgba8(striped(u32::from(u16::MAX) + 4465))).unwrap();
|
||||
assert_eq!(pm.width(), u16::MAX);
|
||||
assert!(red_at_its_coverage(&pm));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ use std::collections::HashMap;
|
||||
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::resample::{Resample, fitted};
|
||||
use vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
@@ -831,8 +832,7 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32)
|
||||
}
|
||||
let tex =
|
||||
vectorcraft_format::base64_decode(png).and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()).map(|img| {
|
||||
let img = if img.width().max(img.height()) > px { img.resize(px, px, image::imageops::FilterType::Triangle) } else { img };
|
||||
let rgba = img.to_rgba8();
|
||||
let rgba = shrunk_thumbnail(img, px);
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
|
||||
ctx.load_texture(format!("library-{library}-{item}-{px}"), image, egui::TextureOptions::LINEAR)
|
||||
});
|
||||
@@ -846,6 +846,13 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32)
|
||||
tex
|
||||
}
|
||||
|
||||
/// Thumbnail `img` shrunk smoothly to at most `px` pixels a side, its colors weighted by its alpha
|
||||
/// so that clear pixels don't darken the edges of the art, as RGBA.
|
||||
fn shrunk_thumbnail(img: image::DynamicImage, px: u32) -> image::RgbaImage {
|
||||
let img = if img.width().max(img.height()) > px { fitted(img, px, px, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img };
|
||||
img.into_rgba8()
|
||||
}
|
||||
|
||||
/// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer).
|
||||
pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) {
|
||||
if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) {
|
||||
|
||||
@@ -525,3 +525,16 @@ fn libraries_are_exported_and_imported_from_the_panel_menu() {
|
||||
assert_eq!(names, ["Brand", "Brand (2)"]);
|
||||
assert_eq!(app.ui.status, "Imported library Brand (2)");
|
||||
}
|
||||
|
||||
/// A graphic's thumbnail shrunk for its tile keeps the color of the art beside clear pixels: the
|
||||
/// clear black beside it does not darken it.
|
||||
#[test]
|
||||
fn a_shrunk_thumbnail_keeps_its_color_beside_clear_pixels() {
|
||||
let img = image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) });
|
||||
let small = shrunk_thumbnail(image::DynamicImage::ImageRgba8(img), 16);
|
||||
assert_eq!(small.dimensions(), (16, 1));
|
||||
let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8;
|
||||
for p in small.pixels() {
|
||||
assert!(p[3] > 0 && [200, 30, 30].iter().zip(p.0).all(|(&want, c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user