mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge pull request #7595 from dotCooCoo/fix/image-scaling-alpha
Images: scaled-down images keep their color beside clear pixels
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)
|
||||
@@ -732,4 +733,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
@@ -23,7 +23,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};
|
||||
@@ -277,15 +278,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,17 +418,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),
|
||||
@@ -425,6 +433,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;
|
||||
|
||||
@@ -482,3 +520,26 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual
|
||||
}
|
||||
Image::from_jpeg_with_icc(out.into(), icc.map(|i| i.to_vec().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