- the world engine splits from the game into a benilla-world crate — 112 files, and the wall is ratcheted to zero names of the game inside the engine - the engine's public doorway is counted and gated at 32 items, and the render materials, shaders and asset cache move down to benilla-assets - the game's files leave the engine's asset root — game:// is its own space — and a spike binary boots the engine with no game attached - weather rides along: snowfall emission is denominated in the reference's frame cap, and a long teleport retires the stranded precipitation field instead of leaving it behind Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
955 lines
38 KiB
Rust
955 lines
38 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`]).
|
||
//!
|
||
//! Three 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; (3) **flicker** ([`envelope`]) — the same arithmetic over *time* instead
|
||
//! of over versions: a burst of adjacent frames from a parked camera (`WOW_LIVE_SHOT_COUNT`), collapsed
|
||
//! to where the picture would not hold still.
|
||
|
||
pub mod relight;
|
||
|
||
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,
|
||
/// How many pixels differ **at all** (any channel, by any amount).
|
||
///
|
||
/// The number that separates "a render changed" from "a silhouette pixel landed on the other
|
||
/// side of an MSAA tie": both read as a scary `max_delta` and an `mae` of 0.000, and only the
|
||
/// count tells them apart. A handful of pixels in a 5.76 M-pixel frame is a tie flipping with
|
||
/// the binary; a region is a regression. Answering that used to take a hand-rolled PNG
|
||
/// decoder in a scratchpad, every time.
|
||
pub changed: u64,
|
||
/// Where the largest delta is, `(x, y)` — `(0, 0)` when nothing differs. A tie flip recurs at
|
||
/// the *same* coordinate build after build, which is most of the evidence that it is one.
|
||
pub worst_at: (u32, u32),
|
||
}
|
||
|
||
/// 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;
|
||
let mut changed = 0u64;
|
||
let mut worst_at = (0u32, 0u32);
|
||
for (i, (pa, pb)) in a.pixels().zip(b.pixels()).enumerate() {
|
||
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);
|
||
if d > max_delta {
|
||
max_delta = d;
|
||
let i = i as u32;
|
||
worst_at = (i % a.width(), i / a.width());
|
||
}
|
||
pixel_max = pixel_max.max(d);
|
||
}
|
||
if pixel_max > 0 {
|
||
changed += 1;
|
||
}
|
||
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,
|
||
changed,
|
||
worst_at,
|
||
})
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// How far a burst of frames moved, and where — the output of [`envelope`].
|
||
#[derive(Debug, Clone)]
|
||
pub struct Envelope {
|
||
/// Per-channel `(max − min)` across the stack, amplified — the picture of *where* it flickers.
|
||
pub image: RgbImage,
|
||
/// Largest single-channel swing anywhere (0..255). The headline "does it flicker at all".
|
||
pub max_swing: u8,
|
||
/// Mean per-channel swing (0..255) over the whole frame.
|
||
pub mean_swing: f64,
|
||
/// Fraction of pixels (0..1) whose largest channel swing exceeds [`OVER_THRESHOLD`].
|
||
pub pct_unstable: f64,
|
||
}
|
||
|
||
/// Collapse a burst of equally-sized frames to their per-pixel **envelope** — `max − min` per channel
|
||
/// across the whole stack. This is the flicker instrument: a still image cannot show a temporal
|
||
/// artefact, but the envelope of adjacent frames from a *parked* camera can, and it localises the
|
||
/// unstable pixels in one picture instead of `n−1` pairwise diffs.
|
||
///
|
||
/// A pixel that holds still contributes 0 whatever its colour, so a scene that is merely *wrong* comes
|
||
/// out black — only what refuses to settle lights up. Errors on fewer than two frames or a size
|
||
/// mismatch.
|
||
pub fn envelope(frames: &[RgbImage], amplify: u32) -> anyhow::Result<Envelope> {
|
||
let (w, h) = burst_dimensions(frames, 2)?;
|
||
let mut image = RgbImage::new(w, h);
|
||
let mut sum_swing = 0u64;
|
||
let mut max_swing = 0u8;
|
||
let mut unstable = 0u64;
|
||
for (x, y, out) in image.enumerate_pixels_mut() {
|
||
let mut pixel_max = 0u8;
|
||
for c in 0..3 {
|
||
let (mut lo, mut hi) = (u8::MAX, u8::MIN);
|
||
for f in frames {
|
||
let v = f.get_pixel(x, y)[c];
|
||
lo = lo.min(v);
|
||
hi = hi.max(v);
|
||
}
|
||
let swing = hi - lo;
|
||
sum_swing += swing as u64;
|
||
max_swing = max_swing.max(swing);
|
||
pixel_max = pixel_max.max(swing);
|
||
out[c] = (swing as u32 * amplify).min(255) as u8;
|
||
}
|
||
if pixel_max > OVER_THRESHOLD {
|
||
unstable += 1;
|
||
}
|
||
}
|
||
let n_pixels = (w as u64) * (h as u64);
|
||
Ok(Envelope {
|
||
image,
|
||
max_swing,
|
||
mean_swing: sum_swing as f64 / (n_pixels * 3).max(1) as f64,
|
||
pct_unstable: unstable as f64 / n_pixels.max(1) as f64,
|
||
})
|
||
}
|
||
|
||
/// How often a burst *reversed direction*, and where — the output of [`toggles`].
|
||
#[derive(Debug, Clone)]
|
||
pub struct Toggles {
|
||
/// Per-channel reversal count across the stack, amplified — the picture of *where* it toggles.
|
||
pub image: RgbImage,
|
||
/// Most reversals any one channel made (0..frames−2).
|
||
pub max_reversals: u32,
|
||
/// Fraction of pixels (0..1) that reversed at least [`TOGGLE_MIN_REVERSALS`] times.
|
||
pub pct_toggling: f64,
|
||
/// Row-major `width × height` flags: did this pixel toggle? Carried out of the scan because
|
||
/// *where* the toggling pixels sit relative to each other is a separate reading ([`shape`]) and
|
||
/// re-thresholding the amplified image would only recover it approximately.
|
||
pub mask: Vec<bool>,
|
||
}
|
||
|
||
/// A pixel counts as "toggling" for [`Toggles::pct_toggling`] at this many direction reversals. Two
|
||
/// is the smallest count that cannot be a single overshoot, and it already needs a four-frame
|
||
/// up-down-up run — which smooth motion across an edge does not produce.
|
||
pub const TOGGLE_MIN_REVERSALS: u32 = 2;
|
||
|
||
/// Count, per pixel and channel, how many times the value **reversed direction** across the burst,
|
||
/// ignoring steps smaller than `min_delta`.
|
||
///
|
||
/// This is [`envelope`]'s companion for the case the envelope cannot serve: **a moving camera**.
|
||
/// When the view is panning, every edge in the frame sweeps across pixels and `max − min` lights up
|
||
/// the whole picture, so it says nothing. But smooth motion is *monotone* — a pixel being crossed by
|
||
/// an edge goes one way and stays going that way — while z-fighting and an unstable draw order
|
||
/// **alternate**, A/B/A/B, however the camera moves. Counting reversals separates the two.
|
||
///
|
||
/// The director's report is what forced this: the Far Watch Post tower "mostly only flickers while
|
||
/// moving the cam, not while still" — the one condition under which a parked burst is blind by
|
||
/// construction (decision 0653's blind spot, named in 0656).
|
||
pub fn toggles(frames: &[RgbImage], min_delta: u8, amplify: u32) -> anyhow::Result<Toggles> {
|
||
let (w, h) = burst_dimensions(frames, 3)?;
|
||
let mut image = RgbImage::new(w, h);
|
||
let mut max_reversals = 0u32;
|
||
let mut toggling = 0u64;
|
||
let mut mask = vec![false; (w as usize) * (h as usize)];
|
||
for (x, y, out) in image.enumerate_pixels_mut() {
|
||
let mut pixel_max = 0u32;
|
||
for c in 0..3 {
|
||
// `dir` is the sign of the last step big enough to count; a run of sub-threshold steps
|
||
// neither reverses nor resets it, so dithering noise cannot manufacture a toggle.
|
||
let (mut dir, mut reversals) = (0i8, 0u32);
|
||
for pair in frames.windows(2) {
|
||
let (a, b) = (
|
||
pair[0].get_pixel(x, y)[c] as i32,
|
||
pair[1].get_pixel(x, y)[c] as i32,
|
||
);
|
||
let step = b - a;
|
||
if step.unsigned_abs() < min_delta as u32 {
|
||
continue;
|
||
}
|
||
let sign = if step > 0 { 1i8 } else { -1i8 };
|
||
if dir != 0 && sign != dir {
|
||
reversals += 1;
|
||
}
|
||
dir = sign;
|
||
}
|
||
max_reversals = max_reversals.max(reversals);
|
||
pixel_max = pixel_max.max(reversals);
|
||
out[c] = (reversals * amplify).min(255) as u8;
|
||
}
|
||
if pixel_max >= TOGGLE_MIN_REVERSALS {
|
||
toggling += 1;
|
||
mask[(y as usize) * (w as usize) + x as usize] = true;
|
||
}
|
||
}
|
||
let n_pixels = (w as u64) * (h as u64);
|
||
Ok(Toggles {
|
||
image,
|
||
max_reversals,
|
||
pct_toggling: toggling as f64 / n_pixels.max(1) as f64,
|
||
mask,
|
||
})
|
||
}
|
||
|
||
/// A rectangle of the frame, in pixels — `x1`/`y1` exclusive.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Rect {
|
||
pub x0: u32,
|
||
pub y0: u32,
|
||
pub x1: u32,
|
||
pub y1: u32,
|
||
}
|
||
|
||
impl Rect {
|
||
pub fn width(&self) -> u32 {
|
||
self.x1 - self.x0
|
||
}
|
||
pub fn height(&self) -> u32 {
|
||
self.y1 - self.y0
|
||
}
|
||
/// Grow by `pad` on every side, clamped to a `w × h` frame.
|
||
pub fn padded(&self, pad: u32, w: u32, h: u32) -> Rect {
|
||
Rect {
|
||
x0: self.x0.saturating_sub(pad),
|
||
y0: self.y0.saturating_sub(pad),
|
||
x1: (self.x1 + pad).min(w),
|
||
y1: (self.y1 + pad).min(h),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One connected run of toggling pixels.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Region {
|
||
pub bounds: Rect,
|
||
/// Toggling pixels in the run (≤ the bounds' area).
|
||
pub pixels: u64,
|
||
/// Up to [`REGION_SAMPLES`] pixels spread evenly through the run, in scan order — coordinates
|
||
/// Every pixel in the run, in scan order. Kept whole because the run's *own* time series
|
||
/// ([`Region::steps`]) is the reading that follows, and it has to be over the run, not over a
|
||
/// bounding box that also contains whatever else was passing through.
|
||
pub members: Vec<(u32, u32)>,
|
||
}
|
||
|
||
/// How many representative pixels [`Region::samples`] hands back.
|
||
pub const REGION_SAMPLES: usize = 8;
|
||
|
||
/// One frame-to-frame step of a run, from [`Region::steps`].
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Step {
|
||
/// The run's mean luma (0..255) in the earlier frame. The *level*, not just the change: a
|
||
/// two-state flip's ratio is what identifies the multiplier behind it, and a delta alone hides it.
|
||
pub mean_from: f64,
|
||
/// The run's mean R/G/B (0..255) in the earlier frame. Whether a two-state flip preserves the
|
||
/// channel ratios says what kind of flip it is: a pure **intensity** scale (one light term
|
||
/// switching on or off) keeps the hue, while swapping to a differently-coloured lighting lane
|
||
/// does not — and luma alone cannot tell those apart.
|
||
pub mean_rgb_from: [f64; 3],
|
||
/// Change in the run's mean luma (0..255 units) from this frame to the next.
|
||
pub mean_delta: f64,
|
||
/// Fraction of the run's pixels (0..1) that moved the **same way** as the mean did. Near 1 = the
|
||
/// whole surface changed together; near 0.5 = as many pixels went up as down.
|
||
pub agreement: f64,
|
||
}
|
||
|
||
impl Region {
|
||
/// How much of its own bounding box the run actually fills, 0..1. A solid surface approaches 1;
|
||
/// a scattering of speckles inside a wide box is near 0.
|
||
pub fn fill(&self) -> f64 {
|
||
let area = u64::from(self.bounds.width()) * u64::from(self.bounds.height());
|
||
self.pixels as f64 / area.max(1) as f64
|
||
}
|
||
|
||
/// Up to [`REGION_SAMPLES`] pixels spread evenly through the run — coordinates that are
|
||
/// *provably* in it, to hand to the in-game ray pick (`WOW_PICK`). Reading them off a heatmap by
|
||
/// eye lands next to the run as often as in it, and then the pick names the wrong surface with
|
||
/// total confidence.
|
||
pub fn samples(&self) -> Vec<(u32, u32)> {
|
||
let step = (self.members.len() / REGION_SAMPLES).max(1);
|
||
self.members
|
||
.iter()
|
||
.copied()
|
||
.step_by(step)
|
||
.take(REGION_SAMPLES)
|
||
.collect()
|
||
}
|
||
|
||
/// The run's own frame-to-frame time series — the reading that separates a surface being
|
||
/// **re-shaded** from one whose **edges are moving**.
|
||
///
|
||
/// [`shape`] says the toggling pixels form one coherent surface; it cannot say what the surface
|
||
/// is doing. If the whole run brightens and dims together (`agreement` near 1, `mean_delta`
|
||
/// swinging), it is being lit differently frame to frame — a shading defect. If half its pixels
|
||
/// go up while the other half go down (`agreement` near 0.5, `mean_delta` near 0), nothing is
|
||
/// being re-lit: a boundary is sweeping across it, and the run is an edge, not a surface.
|
||
pub fn steps(&self, frames: &[RgbImage]) -> Vec<Step> {
|
||
frames
|
||
.windows(2)
|
||
.map(|pair| {
|
||
let mut from = 0.0;
|
||
let mut rgb = [0.0f64; 3];
|
||
let deltas: Vec<f64> = self
|
||
.members
|
||
.iter()
|
||
.map(|&(x, y)| {
|
||
let p = pair[0].get_pixel(x, y);
|
||
for c in 0..3 {
|
||
rgb[c] += f64::from(p[c]);
|
||
}
|
||
let a = luma(p);
|
||
from += a;
|
||
luma(pair[1].get_pixel(x, y)) - a
|
||
})
|
||
.collect();
|
||
let n = deltas.len().max(1) as f64;
|
||
let mean_delta = deltas.iter().sum::<f64>() / n;
|
||
// A pixel that did not move is counted as disagreeing: it is evidence against "the
|
||
// whole surface moved together", which is exactly the claim under test.
|
||
let same = deltas
|
||
.iter()
|
||
.filter(|d| **d != 0.0 && d.is_sign_positive() == mean_delta.is_sign_positive())
|
||
.count();
|
||
Step {
|
||
mean_from: from / n,
|
||
mean_rgb_from: [rgb[0] / n, rgb[1] / n, rgb[2] / n],
|
||
mean_delta,
|
||
agreement: same as f64 / n,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// Rec. 601 luma — one number per pixel for "how bright", so a colour shift and a brightness shift
|
||
/// are not confused with each other.
|
||
fn luma(p: &image::Rgb<u8>) -> f64 {
|
||
0.299 * f64::from(p[0]) + 0.587 * f64::from(p[1]) + 0.114 * f64::from(p[2])
|
||
}
|
||
|
||
/// The **spatial** structure of a toggle map — the reading that separates two defects the toggle
|
||
/// percentage alone cannot tell apart.
|
||
///
|
||
/// Both z-fighting and a visibility flip make pixels alternate, so both score the same on
|
||
/// [`Toggles::pct_toggling`]. They differ in *shape*: z-fighting resolves per fragment, so it lands
|
||
/// as moiré banding or speckle — many small runs, low neighbour agreement. A whole surface blinking
|
||
/// in and out (a culling, portal-visibility or draw-submission flip) lands as **one large solid run**
|
||
/// with the surface's own silhouette — few runs, high fill, high coherence. That distinction decides
|
||
/// which subsystem is at fault, so it is measured rather than eyeballed off a heatmap.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Shape {
|
||
/// Connected runs of at least [`MIN_REGION_PIXELS`], largest first.
|
||
pub regions: Vec<Region>,
|
||
/// Toggling pixels that fell in runs too small to list.
|
||
pub scattered: u64,
|
||
/// Mean fraction of the 4-neighbours of a toggling pixel that also toggle, 0..1. Near 1 = solid
|
||
/// areas; near 0 = isolated pixels. A one-pixel-wide moiré band sits near 0.5.
|
||
pub coherence: f64,
|
||
}
|
||
|
||
/// A connected run smaller than this is noise, not a surface — counted in [`Shape::scattered`].
|
||
pub const MIN_REGION_PIXELS: u64 = 16;
|
||
|
||
/// Group a toggle mask into connected runs (4-connectivity) and measure their coherence.
|
||
pub fn shape(t: &Toggles) -> Shape {
|
||
let (w, h) = t.image.dimensions();
|
||
let (wu, hu) = (w as usize, h as usize);
|
||
let idx = |x: usize, y: usize| y * wu + x;
|
||
|
||
let mut coherent = 0u64;
|
||
let mut toggling = 0u64;
|
||
for y in 0..hu {
|
||
for x in 0..wu {
|
||
if !t.mask[idx(x, y)] {
|
||
continue;
|
||
}
|
||
toggling += 1;
|
||
// Off-frame neighbours count as non-toggling: an edge pixel is genuinely less enclosed,
|
||
// and pretending otherwise would inflate the coherence of anything touching the border.
|
||
let n = u64::from(x > 0 && t.mask[idx(x - 1, y)])
|
||
+ u64::from(x + 1 < wu && t.mask[idx(x + 1, y)])
|
||
+ u64::from(y > 0 && t.mask[idx(x, y - 1)])
|
||
+ u64::from(y + 1 < hu && t.mask[idx(x, y + 1)]);
|
||
coherent += n;
|
||
}
|
||
}
|
||
|
||
// Iterative flood fill — a recursive one blows the stack on a full-screen region.
|
||
let mut seen = vec![false; wu * hu];
|
||
let mut regions: Vec<Region> = Vec::new();
|
||
let mut scattered = 0u64;
|
||
let mut stack: Vec<(usize, usize)> = Vec::new();
|
||
for y0 in 0..hu {
|
||
for x0 in 0..wu {
|
||
if !t.mask[idx(x0, y0)] || seen[idx(x0, y0)] {
|
||
continue;
|
||
}
|
||
seen[idx(x0, y0)] = true;
|
||
stack.push((x0, y0));
|
||
let (mut pixels, mut b) = (
|
||
0u64,
|
||
Rect {
|
||
x0: x0 as u32,
|
||
y0: y0 as u32,
|
||
x1: x0 as u32 + 1,
|
||
y1: y0 as u32 + 1,
|
||
},
|
||
);
|
||
let mut members: Vec<(u32, u32)> = Vec::new();
|
||
while let Some((x, y)) = stack.pop() {
|
||
pixels += 1;
|
||
members.push((x as u32, y as u32));
|
||
b.x0 = b.x0.min(x as u32);
|
||
b.y0 = b.y0.min(y as u32);
|
||
b.x1 = b.x1.max(x as u32 + 1);
|
||
b.y1 = b.y1.max(y as u32 + 1);
|
||
let mut push = |nx: usize, ny: usize, stack: &mut Vec<(usize, usize)>| {
|
||
if t.mask[idx(nx, ny)] && !seen[idx(nx, ny)] {
|
||
seen[idx(nx, ny)] = true;
|
||
stack.push((nx, ny));
|
||
}
|
||
};
|
||
if x > 0 {
|
||
push(x - 1, y, &mut stack);
|
||
}
|
||
if x + 1 < wu {
|
||
push(x + 1, y, &mut stack);
|
||
}
|
||
if y > 0 {
|
||
push(x, y - 1, &mut stack);
|
||
}
|
||
if y + 1 < hu {
|
||
push(x, y + 1, &mut stack);
|
||
}
|
||
}
|
||
if pixels >= MIN_REGION_PIXELS {
|
||
// The flood fill visits in stack order, so sort to scan order — otherwise
|
||
// `Region::samples` spreads its picks through the traversal, not through the run.
|
||
members.sort_unstable_by_key(|&(x, y)| (y, x));
|
||
regions.push(Region {
|
||
bounds: b,
|
||
pixels,
|
||
members,
|
||
});
|
||
} else {
|
||
scattered += pixels;
|
||
}
|
||
}
|
||
}
|
||
regions.sort_by_key(|r| std::cmp::Reverse(r.pixels));
|
||
Shape {
|
||
regions,
|
||
scattered,
|
||
coherence: coherent as f64 / (toggling.max(1) * 4) as f64,
|
||
}
|
||
}
|
||
|
||
/// Cut `rect` out of `img`, clamped to its bounds.
|
||
pub fn crop(img: &RgbImage, rect: Rect) -> RgbImage {
|
||
let r = rect.padded(0, img.width(), img.height());
|
||
let (w, h) = (r.width().max(1), r.height().max(1));
|
||
RgbImage::from_fn(w, h, |x, y| {
|
||
*img.get_pixel(
|
||
(r.x0 + x).min(img.width() - 1),
|
||
(r.y0 + y).min(img.height() - 1),
|
||
)
|
||
})
|
||
}
|
||
|
||
/// Magnify by an integer factor, nearest-neighbour — every output pixel is a source pixel verbatim.
|
||
/// This is the only scaling the `crop` subcommand offers on purpose: a resampler blends neighbours,
|
||
/// and a blended edge has already minted a false finding once (a `sips` downscale's "magenta
|
||
/// fringe" read as a render defect). Zoom may enlarge, never shrink: measurements happen at source
|
||
/// resolution or not at all.
|
||
pub fn zoom(img: &RgbImage, factor: u32) -> RgbImage {
|
||
let s = factor.max(1);
|
||
RgbImage::from_fn(img.width() * s, img.height() * s, |x, y| {
|
||
*img.get_pixel(x / s, y / s)
|
||
})
|
||
}
|
||
|
||
/// Lay tiles out left-to-right, top-to-bottom in a `cols`-wide grid on a mid-grey mat, so a burst's
|
||
/// crops read as one contact sheet. Tiles of different sizes are placed at their own size.
|
||
pub fn contact_strip(tiles: &[RgbImage], cols: u32, gap: u32) -> RgbImage {
|
||
let cols = cols.max(1);
|
||
let (tw, th) = tiles
|
||
.iter()
|
||
.fold((1, 1), |(w, h), t| (w.max(t.width()), h.max(t.height())));
|
||
let rows = (tiles.len() as u32).div_ceil(cols);
|
||
let out_w = cols * tw + (cols + 1) * gap;
|
||
let out_h = rows * th + (rows + 1) * gap;
|
||
let mut out = RgbImage::from_pixel(out_w, out_h, image::Rgb([32, 32, 32]));
|
||
for (i, tile) in tiles.iter().enumerate() {
|
||
let (cx, cy) = (i as u32 % cols, i as u32 / cols);
|
||
let (ox, oy) = (gap + cx * (tw + gap), gap + cy * (th + gap));
|
||
for (x, y, p) in tile.enumerate_pixels() {
|
||
if ox + x < out_w && oy + y < out_h {
|
||
out.put_pixel(ox + x, oy + y, *p);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The common size of a burst, erroring unless it has at least `min` equally-sized frames.
|
||
fn burst_dimensions(frames: &[RgbImage], min: usize) -> anyhow::Result<(u32, u32)> {
|
||
if frames.len() < min {
|
||
anyhow::bail!("needs at least {min} frames, got {}", frames.len());
|
||
}
|
||
let (w, h) = frames[0].dimensions();
|
||
for (i, f) in frames.iter().enumerate() {
|
||
if f.dimensions() != (w, h) {
|
||
anyhow::bail!(
|
||
"frame {i} is {:?}, expected {:?} — a burst must be one camera, one window size",
|
||
f.dimensions(),
|
||
(w, h)
|
||
);
|
||
}
|
||
}
|
||
Ok((w, h))
|
||
}
|
||
|
||
/// 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);
|
||
assert_eq!(m.changed, 0);
|
||
assert_eq!(m.worst_at, (0, 0));
|
||
}
|
||
|
||
/// The single-pixel case this exists for: one silhouette pixel on the wrong side of an MSAA
|
||
/// tie. `mae` rounds to 0.000 and `pct_over` to 0.00% in a frame this size, so the *only*
|
||
/// signals that separate it from a real render change are the count and the coordinate.
|
||
#[test]
|
||
fn one_flipped_pixel_is_counted_and_located() {
|
||
let a = solid(100, 80, [40, 40, 40]);
|
||
let mut b = a.clone();
|
||
b.put_pixel(37, 22, image::Rgb([40, 61, 40]));
|
||
let m = compare(&a, &b).unwrap();
|
||
assert_eq!(m.changed, 1, "exactly one pixel differs");
|
||
assert_eq!(m.worst_at, (37, 22), "and the report says where");
|
||
assert_eq!(m.max_delta, 21);
|
||
assert!(m.mae < 0.001, "a lone pixel vanishes into the mean");
|
||
}
|
||
|
||
#[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 zoom_is_pixel_verbatim() {
|
||
// A 2x1 two-colour image zoomed ×3: every output pixel must be one of the two source
|
||
// values, block-aligned — any third colour would mean a resampler blended an edge.
|
||
let mut img = solid(2, 1, [10, 20, 30]);
|
||
img.put_pixel(1, 0, Rgb([200, 100, 50]));
|
||
let z = zoom(&img, 3);
|
||
assert_eq!(z.dimensions(), (6, 3));
|
||
for (x, _, p) in z.enumerate_pixels() {
|
||
let want = if x < 3 { [10, 20, 30] } else { [200, 100, 50] };
|
||
assert_eq!(p.0, want);
|
||
}
|
||
// Factor 0 clamps to 1 (identity), never a panic or an empty image.
|
||
assert_eq!(zoom(&img, 0).dimensions(), img.dimensions());
|
||
}
|
||
|
||
#[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 a_still_burst_has_no_envelope() {
|
||
// The point of max−min: a scene that never moves comes out black however bright it is, so a
|
||
// merely *wrong* render cannot masquerade as a flickering one.
|
||
let img = solid(4, 4, [200, 30, 90]);
|
||
let e = envelope(&[img.clone(), img.clone(), img], 8).unwrap();
|
||
assert_eq!(e.max_swing, 0);
|
||
assert_eq!(e.mean_swing, 0.0);
|
||
assert_eq!(e.pct_unstable, 0.0);
|
||
assert_eq!(e.image.get_pixel(0, 0), &Rgb([0, 0, 0]));
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_spans_the_whole_stack_not_just_neighbours() {
|
||
// 10 → 60 → 10: consecutive pairs each swing 50, and so does the envelope. But a value that
|
||
// drifts monotonically (10 → 35 → 60) swings only 25 per pair while the envelope still says
|
||
// 50 — which is the reason this is a stack operation and not a chain of pairwise diffs.
|
||
let base = solid(2, 1, [10, 10, 10]);
|
||
let alternating = {
|
||
let mut m = base.clone();
|
||
m.put_pixel(0, 0, Rgb([60, 10, 10]));
|
||
m
|
||
};
|
||
let drifting = {
|
||
let mut m = base.clone();
|
||
m.put_pixel(0, 0, Rgb([35, 10, 10]));
|
||
m
|
||
};
|
||
for stack in [
|
||
[base.clone(), alternating.clone(), base.clone()],
|
||
[base.clone(), drifting, alternating],
|
||
] {
|
||
let e = envelope(&stack, 1).unwrap();
|
||
assert_eq!(e.max_swing, 50);
|
||
assert_eq!(e.image.get_pixel(0, 0), &Rgb([50, 0, 0]));
|
||
assert_eq!(e.image.get_pixel(1, 0), &Rgb([0, 0, 0]));
|
||
// one unstable pixel of two
|
||
assert!((e.pct_unstable - 0.5).abs() < 1e-9);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_amplifies_and_clamps_like_the_diff() {
|
||
let a = solid(1, 1, [10, 10, 10]);
|
||
let b = solid(1, 1, [20, 10, 10]);
|
||
assert_eq!(
|
||
envelope(&[a.clone(), b.clone()], 8)
|
||
.unwrap()
|
||
.image
|
||
.get_pixel(0, 0),
|
||
&Rgb([80, 0, 0])
|
||
);
|
||
assert_eq!(
|
||
envelope(&[a, b], 32).unwrap().image.get_pixel(0, 0),
|
||
&Rgb([255, 0, 0])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_needs_a_burst_and_one_window_size() {
|
||
let a = solid(4, 4, [0, 0, 0]);
|
||
assert!(envelope(&[], 8).is_err());
|
||
assert!(envelope(std::slice::from_ref(&a), 8).is_err());
|
||
assert!(envelope(&[a, solid(4, 5, [0, 0, 0])], 8).is_err());
|
||
}
|
||
|
||
/// One red value per frame, at pixel (0,0) of a 1×1 burst.
|
||
fn ramp(reds: &[u8]) -> Vec<RgbImage> {
|
||
reds.iter()
|
||
.map(|r| RgbImage::from_pixel(1, 1, Rgb([*r, 0, 0])))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_monotone_sweep_never_toggles_however_far_it_moves() {
|
||
// The whole point: this is what a moving camera does to a pixel an edge sweeps across —
|
||
// a huge envelope (0 → 240) and zero reversals. `envelope` calls it maximally unstable;
|
||
// `toggles` correctly says it is not flickering.
|
||
let sweep = ramp(&[0, 60, 120, 180, 240]);
|
||
assert_eq!(envelope(&sweep, 1).unwrap().max_swing, 240);
|
||
let t = toggles(&sweep, 4, 60).unwrap();
|
||
assert_eq!(t.max_reversals, 0);
|
||
assert_eq!(t.pct_toggling, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_alternating_pixel_toggles_every_step() {
|
||
// A/B/A/B/A — z-fighting's signature. Four steps, three of them reversals.
|
||
let t = toggles(&ramp(&[10, 90, 10, 90, 10]), 4, 60).unwrap();
|
||
assert_eq!(t.max_reversals, 3);
|
||
assert_eq!(t.pct_toggling, 1.0);
|
||
assert_eq!(t.image.get_pixel(0, 0), &Rgb([180, 0, 0])); // 3 × 60, unclamped
|
||
}
|
||
|
||
#[test]
|
||
fn sub_threshold_noise_neither_toggles_nor_forgets_the_direction() {
|
||
// ±1 dither must not manufacture reversals …
|
||
let t = toggles(&ramp(&[50, 51, 50, 51, 50, 51]), 4, 60).unwrap();
|
||
assert_eq!(t.max_reversals, 0);
|
||
// … and a run of it in the middle of a real climb must not reset the direction either,
|
||
// or every noisy edge would read as one reversal per crossing.
|
||
let t = toggles(&ramp(&[0, 40, 41, 40, 41, 80]), 4, 60).unwrap();
|
||
assert_eq!(t.max_reversals, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_single_overshoot_is_one_reversal_and_below_the_toggle_bar() {
|
||
// Down-then-up once: real, but not a flicker — `TOGGLE_MIN_REVERSALS` is what draws that
|
||
// line, so a lone direction change must not be counted as a toggling pixel.
|
||
let t = toggles(&ramp(&[10, 90, 40]), 4, 60).unwrap();
|
||
assert_eq!(t.max_reversals, 1);
|
||
assert_eq!(t.pct_toggling, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn toggles_needs_three_frames_and_one_window_size() {
|
||
let a = solid(4, 4, [0, 0, 0]);
|
||
assert!(toggles(&[a.clone(), a.clone()], 4, 60).is_err());
|
||
assert!(toggles(&[a.clone(), a.clone(), solid(4, 5, [0, 0, 0])], 4, 60).is_err());
|
||
assert!(toggles(&[a.clone(), a.clone(), a], 4, 60).is_ok());
|
||
}
|
||
|
||
#[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
|
||
}
|
||
|
||
/// A burst in which exactly the pixels selected by `flips` alternate 0/200 and everything else
|
||
/// holds still — the shape tests differ only in *which* pixels those are.
|
||
fn alternating(
|
||
w: u32,
|
||
h: u32,
|
||
frames: usize,
|
||
flips: impl Fn(u32, u32) -> bool,
|
||
) -> Vec<RgbImage> {
|
||
(0..frames)
|
||
.map(|i| {
|
||
RgbImage::from_fn(w, h, |x, y| {
|
||
let on = flips(x, y) && i % 2 == 1;
|
||
Rgb([if on { 200 } else { 0 }, 0, 0])
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn one_solid_block_reads_as_a_single_filled_run() {
|
||
// A whole surface blinking: one run, filling its own box, every interior pixel enclosed.
|
||
let frames = alternating(20, 20, 6, |x, y| {
|
||
(4..12).contains(&x) && (4..12).contains(&y)
|
||
});
|
||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||
assert_eq!(s.regions.len(), 1);
|
||
assert_eq!(s.regions[0].pixels, 64);
|
||
assert_eq!(
|
||
s.regions[0].bounds,
|
||
Rect {
|
||
x0: 4,
|
||
y0: 4,
|
||
x1: 12,
|
||
y1: 12
|
||
}
|
||
);
|
||
assert!((s.regions[0].fill() - 1.0).abs() < 1e-9);
|
||
assert_eq!(s.scattered, 0);
|
||
// 8x8 block: 4 corners see 2 neighbours, 24 edges see 3, 36 interior see 4 → 224/256.
|
||
assert!(
|
||
(s.coherence - 224.0 / 256.0).abs() < 1e-9,
|
||
"{}",
|
||
s.coherence
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_checkerboard_covers_the_same_area_with_none_of_the_shape() {
|
||
// Z-fighting resolving per fragment. Same region of the frame, ~half the pixels toggling,
|
||
// but no pixel touches another — the reading that separates it from the block above.
|
||
let frames = alternating(20, 20, 6, |x, y| {
|
||
(4..12).contains(&x) && (4..12).contains(&y) && (x + y) % 2 == 0
|
||
});
|
||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||
assert_eq!(s.coherence, 0.0);
|
||
assert!(s.regions.is_empty(), "isolated pixels are not runs");
|
||
assert_eq!(s.scattered, 32);
|
||
}
|
||
|
||
#[test]
|
||
fn runs_come_back_largest_first_and_short_ones_are_scattered() {
|
||
// Two blocks plus a speck: the big one leads, the speck falls below MIN_REGION_PIXELS.
|
||
let frames = alternating(40, 20, 6, |x, y| {
|
||
let big = (2..12).contains(&x) && (2..12).contains(&y); // 100 px
|
||
let small = (20..25).contains(&x) && (2..7).contains(&y); // 25 px
|
||
let speck = (30..33).contains(&x) && y == 10; // 3 px — noise
|
||
big || small || speck
|
||
});
|
||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||
assert_eq!(
|
||
s.regions.iter().map(|r| r.pixels).collect::<Vec<_>>(),
|
||
vec![100, 25]
|
||
);
|
||
assert_eq!(s.scattered, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn a_still_burst_has_no_shape_at_all() {
|
||
let frames = alternating(8, 8, 6, |_, _| false);
|
||
let s = shape(&toggles(&frames, 4, 60).unwrap());
|
||
assert!(s.regions.is_empty());
|
||
assert_eq!(s.scattered, 0);
|
||
assert_eq!(s.coherence, 0.0); // no toggling pixels — not a divide-by-zero
|
||
}
|
||
|
||
#[test]
|
||
fn crop_takes_the_named_rect() {
|
||
let img = RgbImage::from_fn(10, 10, |x, y| Rgb([x as u8, y as u8, 0]));
|
||
let out = crop(
|
||
&img,
|
||
Rect {
|
||
x0: 3,
|
||
y0: 4,
|
||
x1: 6,
|
||
y1: 9,
|
||
},
|
||
);
|
||
assert_eq!(out.dimensions(), (3, 5));
|
||
assert_eq!(out.get_pixel(0, 0), &Rgb([3, 4, 0]));
|
||
assert_eq!(out.get_pixel(2, 4), &Rgb([5, 8, 0]));
|
||
}
|
||
|
||
#[test]
|
||
fn padding_a_rect_stops_at_the_frame_edge() {
|
||
let r = Rect {
|
||
x0: 2,
|
||
y0: 2,
|
||
x1: 5,
|
||
y1: 5,
|
||
};
|
||
assert_eq!(
|
||
r.padded(10, 8, 8),
|
||
Rect {
|
||
x0: 0,
|
||
y0: 0,
|
||
x1: 8,
|
||
y1: 8
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_contact_strip_lays_tiles_out_in_a_grid() {
|
||
let tiles: Vec<RgbImage> = (0..3)
|
||
.map(|i| RgbImage::from_pixel(2, 2, Rgb([10 * (i + 1) as u8, 0, 0])))
|
||
.collect();
|
||
let out = contact_strip(&tiles, 2, 1);
|
||
assert_eq!(out.dimensions(), (2 * 2 + 3, 2 * 2 + 3)); // 2 cols, 2 rows, 1px mat
|
||
assert_eq!(out.get_pixel(1, 1), &Rgb([10, 0, 0])); // tile 0
|
||
assert_eq!(out.get_pixel(4, 1), &Rgb([20, 0, 0])); // tile 1, next column
|
||
assert_eq!(out.get_pixel(1, 4), &Rgb([30, 0, 0])); // tile 2, wrapped
|
||
assert_eq!(out.get_pixel(4, 4), &Rgb([32, 32, 32])); // empty cell stays mat
|
||
}
|
||
}
|