blob: ad632f206e907bfb9e6c5d6c55423782cb7b361f [file] [edit]
// Copyright 2025 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Pixel comparison shared by the snapshot suite and the fuzz targets.
use image::{Rgba, RgbaImage};
use serde::Serializer;
use std::cmp::max;
/// Aggregate diff report with statistics and individual pixel differences.
#[derive(Debug, serde::Serialize)]
pub struct DiffReport {
/// Total number of pixels that differ.
pub pixel_count: usize,
/// Maximum absolute difference per channel [R, G, B, A].
pub max_difference: [i16; 4],
/// Individual pixel differences.
pub pixels: Vec<PixelDiff>,
}
impl DiffReport {
pub fn new(pixels: Vec<PixelDiff>) -> Self {
let max_difference = pixels.iter().fold([0; 4], |mut max, p| {
for (m, d) in max.iter_mut().zip(&p.difference) {
*m = (*m).max(d.abs());
}
max
});
Self {
pixel_count: pixels.len(),
max_difference,
pixels,
}
}
}
/// Represents a single pixel difference between reference and actual images.
#[derive(Debug, serde::Serialize)]
pub struct PixelDiff {
/// The x coordinate of the differing pixel.
pub x: u32,
/// The y coordinate of the differing pixel.
pub y: u32,
/// The RGBA values from the target image (i.e. the saved reference).
// Note that this field name is chosen to be the same length as `actual`
// That makes it easier to compare the results in the printed JSON.
#[serde(serialize_with = "hex_string")]
pub target: [u8; 4],
/// The RGBA values from the actual image.
#[serde(serialize_with = "hex_string")]
pub actual: [u8; 4],
/// Per-channel difference (actual - target) as signed values.
pub difference: [i16; 4],
}
/// Serialize a [`[u8; 4]`](primitive@array) pixel as a hex string through serde.
///
/// E.g. `[0, 255, 0, 255]` becomes #00ff00. Notice that the alpha is not included if fully opaque.
fn hex_string<S>([r, g, b, a]: &[u8; 4], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if *a != 255 {
serializer.collect_str(&format_args!("#{r:02x}{g:02x}{b:02x}{a:02x}"))
} else {
serializer.collect_str(&format_args!("#{r:02x}{g:02x}{b:02x}"))
}
}
/// Compares two images and, if more than `diff_pixels` pixels differ by more than `threshold`
/// in any colour channel, returns an `expected | diff | actual` image and the differing pixels.
pub fn get_diff(
expected_image: &RgbaImage,
actual_image: &RgbaImage,
threshold: u8,
diff_pixels: u32,
) -> Option<(RgbaImage, Vec<PixelDiff>)> {
let width = max(expected_image.width(), actual_image.width());
let height = max(expected_image.height(), actual_image.height());
let mut diff_image = RgbaImage::new(width * 3, height);
let mut diff_data = Vec::new();
let mut pixel_diff = 0;
for x in 0..width {
for y in 0..height {
let actual_pixel = actual_image.get_pixel_checked(x, y);
let expected_pixel = expected_image.get_pixel_checked(x, y);
match (actual_pixel, expected_pixel) {
(Some(actual), Some(expected)) => {
diff_image.put_pixel(x, y, *expected);
diff_image.put_pixel(x + 2 * width, y, *actual);
if is_pix_diff(expected, actual, threshold) {
pixel_diff += 1;
diff_image.put_pixel(x + width, y, Rgba([255, 0, 0, 255]));
diff_data.push(PixelDiff {
x,
y,
target: expected.0,
actual: actual.0,
difference: [
i16::from(actual.0[0]) - i16::from(expected.0[0]),
i16::from(actual.0[1]) - i16::from(expected.0[1]),
i16::from(actual.0[2]) - i16::from(expected.0[2]),
i16::from(actual.0[3]) - i16::from(expected.0[3]),
],
});
} else {
diff_image.put_pixel(x + width, y, Rgba([0, 0, 0, 255]));
}
}
(Some(actual), None) => {
pixel_diff += 1;
diff_image.put_pixel(x + 2 * width, y, *actual);
diff_image.put_pixel(x + width, y, Rgba([255, 0, 0, 255]));
diff_data.push(PixelDiff {
x,
y,
target: [0, 0, 0, 0],
actual: actual.0,
difference: [
i16::from(actual.0[0]),
i16::from(actual.0[1]),
i16::from(actual.0[2]),
i16::from(actual.0[3]),
],
});
}
(None, Some(expected)) => {
pixel_diff += 1;
diff_image.put_pixel(x, y, *expected);
diff_image.put_pixel(x + width, y, Rgba([255, 0, 0, 255]));
diff_data.push(PixelDiff {
x,
y,
target: expected.0,
actual: [0, 0, 0, 0],
difference: [
-i16::from(expected.0[0]),
-i16::from(expected.0[1]),
-i16::from(expected.0[2]),
-i16::from(expected.0[3]),
],
});
}
_ => {
pixel_diff += 1;
diff_image.put_pixel(x, y, Rgba([255, 0, 0, 255]));
diff_image.put_pixel(x + width, y, Rgba([255, 0, 0, 255]));
diff_data.push(PixelDiff {
x,
y,
target: [0, 0, 0, 0],
actual: [0, 0, 0, 0],
difference: [0, 0, 0, 0],
});
}
}
}
}
if pixel_diff > diff_pixels {
Some((diff_image, diff_data))
} else {
None
}
}
fn is_pix_diff(pixel1: &Rgba<u8>, pixel2: &Rgba<u8>, threshold: u8) -> bool {
if pixel1.0[3] == 0 && pixel2.0[3] == 0 {
return false;
}
let mut different = false;
for i in 0..3 {
let difference = pixel1.0[i].abs_diff(pixel2.0[i]);
different |= difference > threshold;
}
different
}
/// Writes `<stem>.png` and `<stem>.json`, creating the parent directory if needed, and returns
/// both paths. The extensions are appended so a stem containing dots is preserved.
#[cfg(not(target_arch = "wasm32"))]
pub fn write_diff(
stem: &std::path::Path,
diff_image: &RgbaImage,
report: &DiffReport,
) -> (std::path::PathBuf, std::path::PathBuf) {
if let Some(parent) = stem.parent() {
let _ = std::fs::create_dir_all(parent);
}
let with_suffix = |suffix: &str| {
let mut path = stem.as_os_str().to_owned();
path.push(suffix);
std::path::PathBuf::from(path)
};
let image_path = with_suffix(".png");
diff_image
.save_with_format(&image_path, image::ImageFormat::Png)
.unwrap();
let json_path = with_suffix(".json");
std::fs::write(&json_path, serde_json::to_string_pretty(report).unwrap()).unwrap();
(image_path, json_path)
}