Files
benilla-octo/crates/benilla-visual/src/lib.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

191 lines
7.3 KiB
Rust

//! Perceptual image-diff metrics for the Phase-5 visual A/B render harness (decision 0008).
//!
//! The harness captures deterministic screenshots of benilla (`$WOW_CAPTURE`, see the `capture` module
//! in the `benilla` crate) and diffs them. This crate is the pure-math half: given two equally-sized
//! images it reports how far they diverge ([`Metrics`]) and renders a heatmap of *where* ([`diff_image`]).
//!
//! Two uses, one tool: (1) **self-regression** — capture baselines on the current pipeline, then diff
//! every linear-HDR rework step against them so a machine catches a regression before the director's
//! eye; (2) **determinism check** — diffing two captures of the same scenario must come out ≈0, which is
//! what makes (1) trustworthy.
use image::RgbImage;
/// A pixel counts as "changed" for [`Metrics::pct_over`] if any channel differs by more than this many
/// byte units. 8/255 ≈ 3% — above dithering/rounding noise, below a real visual shift.
pub const OVER_THRESHOLD: u8 = 8;
/// Per-image difference metrics. Channel deltas are in 0..255 byte units; `pct_over` is a fraction 0..1.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Metrics {
/// Mean absolute per-channel difference (0..255). The headline "how different" number.
pub mae: f64,
/// Root-mean-square per-channel difference (0..255) — weights large local deltas more than `mae`.
pub rmse: f64,
/// Largest single-channel difference anywhere (0..255).
pub max_delta: u8,
/// Fraction of pixels (0..1) whose largest channel delta exceeds [`OVER_THRESHOLD`].
pub pct_over: f64,
}
/// Compare two equally-sized RGB images. Errors if the dimensions differ.
pub fn compare(a: &RgbImage, b: &RgbImage) -> anyhow::Result<Metrics> {
if a.dimensions() != b.dimensions() {
anyhow::bail!(
"image size mismatch: {:?} vs {:?}",
a.dimensions(),
b.dimensions()
);
}
let mut sum_abs = 0u64;
let mut sum_sq = 0u64;
let mut max_delta = 0u8;
let mut over = 0u64;
for (pa, pb) in a.pixels().zip(b.pixels()) {
let mut pixel_max = 0u8;
for c in 0..3 {
let d = (pa[c] as i32 - pb[c] as i32).unsigned_abs() as u8;
sum_abs += d as u64;
sum_sq += (d as u64) * (d as u64);
max_delta = max_delta.max(d);
pixel_max = pixel_max.max(d);
}
if pixel_max > OVER_THRESHOLD {
over += 1;
}
}
let n_pixels = (a.width() as u64) * (a.height() as u64);
let n_chan = (n_pixels * 3).max(1) as f64;
Ok(Metrics {
mae: sum_abs as f64 / n_chan,
rmse: (sum_sq as f64 / n_chan).sqrt(),
max_delta,
pct_over: over as f64 / n_pixels.max(1) as f64,
})
}
/// Render an amplified per-channel abs-difference image: each output channel is `|a-b| * amplify`,
/// clamped to 255. This colourises both *where* and *in which channel* the images diverge (a red shift
/// shows red), so the diff is readable at a glance. Errors on a size mismatch.
pub fn diff_image(a: &RgbImage, b: &RgbImage, amplify: u32) -> anyhow::Result<RgbImage> {
if a.dimensions() != b.dimensions() {
anyhow::bail!(
"image size mismatch: {:?} vs {:?}",
a.dimensions(),
b.dimensions()
);
}
let (w, h) = a.dimensions();
let mut out = RgbImage::new(w, h);
for (x, y, p) in out.enumerate_pixels_mut() {
let pa = a.get_pixel(x, y);
let pb = b.get_pixel(x, y);
for c in 0..3 {
let d = (pa[c] as i32 - pb[c] as i32).unsigned_abs();
p[c] = (d * amplify).min(255) as u8;
}
}
Ok(out)
}
/// Stitch two images side by side (`left | right`) with a `gap`-px dark separator, for at-a-glance A/B
/// comparison (e.g. the faithful vs modern render). Heights may differ; the output is the max height
/// with each image top-aligned.
pub fn compose_side_by_side(left: &RgbImage, right: &RgbImage, gap: u32) -> RgbImage {
let h = left.height().max(right.height());
let w = left.width() + gap + right.width();
let mut out = image::RgbImage::from_pixel(w, h, image::Rgb([24, 24, 24]));
image::imageops::overlay(&mut out, left, 0, 0);
image::imageops::overlay(&mut out, right, (left.width() + gap) as i64, 0);
out
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgb;
fn solid(w: u32, h: u32, rgb: [u8; 3]) -> RgbImage {
RgbImage::from_pixel(w, h, Rgb(rgb))
}
#[test]
fn identical_images_are_zero() {
let img = solid(4, 4, [120, 60, 200]);
let m = compare(&img, &img).unwrap();
assert_eq!(m.max_delta, 0);
assert_eq!(m.mae, 0.0);
assert_eq!(m.rmse, 0.0);
assert_eq!(m.pct_over, 0.0);
}
#[test]
fn constant_offset_matches_offset() {
// Every channel of `b` is 10 below `a`, so mae == rmse == max_delta == 10, and (10 > 8) so
// every pixel is "over" → pct_over == 1.
let a = solid(8, 5, [100, 100, 100]);
let b = solid(8, 5, [90, 90, 90]);
let m = compare(&a, &b).unwrap();
assert_eq!(m.max_delta, 10);
assert!((m.mae - 10.0).abs() < 1e-9);
assert!((m.rmse - 10.0).abs() < 1e-9);
assert!((m.pct_over - 1.0).abs() < 1e-9);
}
#[test]
fn small_offset_is_under_threshold() {
// A 5-byte shift is below OVER_THRESHOLD (8), so no pixel counts as changed even though mae>0.
let a = solid(8, 5, [100, 100, 100]);
let b = solid(8, 5, [95, 95, 95]);
let m = compare(&a, &b).unwrap();
assert_eq!(m.max_delta, 5);
assert_eq!(m.pct_over, 0.0);
}
#[test]
fn single_channel_delta() {
// Only the red channel differs by 30 in one of four pixels.
let a = solid(2, 2, [10, 10, 10]);
let mut b = a.clone();
b.put_pixel(0, 0, Rgb([40, 10, 10]));
let m = compare(&a, &b).unwrap();
assert_eq!(m.max_delta, 30);
// one changed pixel of four
assert!((m.pct_over - 0.25).abs() < 1e-9);
// 30 over 12 channels = 2.5
assert!((m.mae - 2.5).abs() < 1e-9);
}
#[test]
fn size_mismatch_errors() {
let a = solid(4, 4, [0, 0, 0]);
let b = solid(4, 5, [0, 0, 0]);
assert!(compare(&a, &b).is_err());
}
#[test]
fn diff_image_amplifies_and_clamps() {
let a = solid(2, 1, [10, 10, 10]);
let mut b = a.clone();
b.put_pixel(0, 0, Rgb([20, 10, 10])); // red delta 10
let d = diff_image(&a, &b, 8).unwrap();
// 10*8 = 80 in red at (0,0); other channels/pixels zero.
assert_eq!(d.get_pixel(0, 0), &Rgb([80, 0, 0]));
assert_eq!(d.get_pixel(1, 0), &Rgb([0, 0, 0]));
// amplify saturates: 10*32 = 320 -> 255.
let d2 = diff_image(&a, &b, 32).unwrap();
assert_eq!(d2.get_pixel(0, 0), &Rgb([255, 0, 0]));
}
#[test]
fn compose_places_both_with_gap() {
let l = solid(3, 2, [10, 20, 30]);
let r = solid(4, 2, [40, 50, 60]);
let out = compose_side_by_side(&l, &r, 1);
assert_eq!(out.dimensions(), (3 + 1 + 4, 2));
assert_eq!(out.get_pixel(0, 0), &Rgb([10, 20, 30])); // left image
assert_eq!(out.get_pixel(3, 0), &Rgb([24, 24, 24])); // separator
assert_eq!(out.get_pixel(4, 0), &Rgb([40, 50, 60])); // right image
}
}