vectorcraft/crates/testkit/src/raster.rs

175 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Rendering helpers and image comparison with a perceptual tolerance.
use vectorcraft_doc::Document;
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_render::{RenderOptions, Rendered, Renderer};
/// A straight-alpha RGBA8 image.
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
impl Image {
pub fn from_rendered(r: &Rendered) -> Self {
Self { width: r.width, height: r.height, rgba: r.to_straight() }
}
/// Decode a PNG.
pub fn from_png(bytes: &[u8]) -> Result<Self, String> {
let img = image::load_from_memory_with_format(bytes, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
Ok(Self { width: img.width(), height: img.height(), rgba: img.into_raw() })
}
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = ((y * self.width + x) * 4) as usize;
[self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
}
/// The pixel composited over opaque white (what a viewer sees).
pub fn over_white(&self, x: u32, y: u32) -> [u8; 3] {
let [r, g, b, a] = self.pixel(x, y);
let f = |c: u8| ((c as u32 * a as u32 + 255 * (255 - a as u32) + 127) / 255) as u8;
[f(r), f(g), f(b)]
}
/// Number of pixels for which `pred(rgba)` holds.
pub fn count(&self, pred: impl Fn([u8; 4]) -> bool) -> usize {
self.rgba.chunks_exact(4).filter(|p| pred([p[0], p[1], p[2], p[3]])).count()
}
/// Pixels that differ from opaque white and are not fully transparent.
pub fn ink(&self) -> usize {
self.count(|p| p[3] > 0 && !(p[0] > 250 && p[1] > 250 && p[2] > 250))
}
/// Save as PNG (for debugging failed comparisons).
pub fn save_png(&self, path: &std::path::Path) {
if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) {
let _ = img.save(path);
}
}
/// Luminance over white, 0..255 as f64, per pixel.
fn luma_over_white(&self) -> Vec<[f64; 3]> {
(0..self.height).flat_map(|y| (0..self.width).map(move |x| (x, y))).map(|(x, y)| self.over_white(x, y).map(|c| c as f64)).collect()
}
}
/// Render `doc` at `scale` px/pt over the region `r` on a white background.
pub fn render_region(doc: &Document, r: Rect, scale: f64) -> Image {
Image::from_rendered(&Renderer::new().render_region(doc, r, scale, true))
}
/// Render the first artboard at 1 px/pt on white.
pub fn render_artboard(doc: &Document) -> Image {
render_region(doc, doc.artboards[0].rect, 1.0)
}
/// Render `w`×`h` pixels with an identity view (doc points = pixels) on white.
pub fn render_view(doc: &Document, w: u32, h: u32) -> Image {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
Image::from_rendered(&Renderer::new().render(doc, w, h, Affine::IDENTITY, &opts))
}
/// Max per-channel difference between two pixels.
pub fn channel_diff(a: [u8; 4], b: [u8; 4]) -> u8 {
(0..4).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0)
}
/// Assert a pixel is within `tol` (per channel) of `want`.
#[track_caller]
pub fn assert_pixel(img: &Image, x: u32, y: u32, want: [u8; 4], tol: u8) {
let got = img.pixel(x, y);
assert!(channel_diff(got, want) <= tol, "pixel ({x},{y}) = {got:?}, want {want:?} ±{tol}");
}
/// Assert a pixel, composited over white, is within `tol` of `want` RGB.
#[track_caller]
pub fn assert_rgb(img: &Image, x: u32, y: u32, want: [u8; 3], tol: u8) {
let got = img.over_white(x, y);
let d = (0..3).map(|i| got[i].abs_diff(want[i])).max().unwrap();
assert!(d <= tol, "pixel ({x},{y}) = {got:?}, want {want:?} ±{tol}");
}
/// Statistics from comparing two same-sized images.
#[derive(Clone, Copy, Debug, Default)]
pub struct DiffStats {
/// Largest per-channel difference over white.
pub max: u8,
/// Mean absolute difference over all channels (0..255).
pub mean: f64,
/// Pixels whose (blurred, perceptual) difference exceeds the tolerance.
pub bad_pixels: usize,
pub pixels: usize,
}
/// Exact per-pixel comparison (composited over white).
pub fn diff(a: &Image, b: &Image) -> DiffStats {
assert_eq!((a.width, a.height), (b.width, b.height), "image sizes differ");
let (la, lb) = (a.luma_over_white(), b.luma_over_white());
let mut st = DiffStats { pixels: la.len(), ..Default::default() };
let mut sum = 0.0;
for (p, q) in la.iter().zip(&lb) {
for c in 0..3 {
let d = (p[c] - q[c]).abs();
sum += d;
st.max = st.max.max(d as u8);
}
}
st.mean = sum / (la.len() * 3).max(1) as f64;
st
}
/// Perceptual comparison: both images are box-blurred (radius 1) to forgive sub-pixel
/// anti-aliasing shifts, then compared with a luminance-weighted colour distance. A pixel is "bad"
/// when that distance exceeds `tol` (0..255 scale).
pub fn perceptual_diff(a: &Image, b: &Image, tol: f64) -> DiffStats {
assert_eq!((a.width, a.height), (b.width, b.height), "image sizes differ");
let (w, h) = (a.width as i64, a.height as i64);
let blur = |img: &Image| {
let l = img.luma_over_white();
let mut out = vec![[0.0; 3]; l.len()];
for y in 0..h {
for x in 0..w {
let mut acc = [0.0; 3];
let mut n = 0.0;
for dy in -1..=1 {
for dx in -1..=1 {
let (xx, yy) = (x + dx, y + dy);
if xx >= 0 && yy >= 0 && xx < w && yy < h {
let p = l[(yy * w + xx) as usize];
for c in 0..3 {
acc[c] += p[c];
}
n += 1.0;
}
}
}
out[(y * w + x) as usize] = acc.map(|v| v / n);
}
}
out
};
let (ba, bb) = (blur(a), blur(b));
let mut st = diff(a, b);
st.bad_pixels = 0;
for (p, q) in ba.iter().zip(&bb) {
// Rec. 601 luma weights for the perceptual distance; chroma counts at half weight.
let dy = 0.299 * (p[0] - q[0]) + 0.587 * (p[1] - q[1]) + 0.114 * (p[2] - q[2]);
let dc = ((p[0] - q[0]).abs() + (p[1] - q[1]).abs() + (p[2] - q[2]).abs()) / 3.0;
if dy.abs().max(dc * 0.5) > tol {
st.bad_pixels += 1;
}
}
st
}
/// Assert two images are perceptually equal: at most `max_bad_fraction` of pixels exceed `tol`.
#[track_caller]
pub fn assert_similar(a: &Image, b: &Image, tol: f64, max_bad_fraction: f64) {
let st = perceptual_diff(a, b, tol);
let frac = st.bad_pixels as f64 / st.pixels.max(1) as f64;
if frac > max_bad_fraction {
let dir = crate::temp_dir("raster");
a.save_png(&dir.join("a.png"));
b.save_png(&dir.join("b.png"));
panic!("images differ: {st:?} ({:.3}% bad > {:.3}%), saved to {}", frac * 100.0, max_bad_fraction * 100.0, dir.display());
}
}