//! `benilla-visual` — diff captures from the Phase-5 visual A/B harness (decision 0008). //! //! Usage: //! benilla-visual diff [--out ] [--fail ] [--amplify ] //! benilla-visual diff-dir [--out ] [--fail ] [--amplify ] //! benilla-visual flicker [--out ] [--fail ] [--amplify ] //! //! `diff` compares two images; `diff-dir` compares every `*.png` present in *both* directories by name. //! Prints the metrics; writes amplified heatmap(s) when `--out` is given; exits non-zero if any image's //! MAE exceeds `--fail` (when given). Typical loop: capture baselines, change the renderer, re-capture, //! `diff-dir baseline candidate --out diff --fail 1.5`. //! //! `flicker` is the same arithmetic over *time*: point it at a `WOW_LIVE_SHOT_COUNT` burst (adjacent //! frames, parked camera) and it prints the frame-to-frame table and the whole-stack envelope, and //! writes the envelope heatmap — the picture of which pixels would not hold still. use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use benilla_visual::{ compare, compose_side_by_side, contact_strip, crop, diff_image, envelope, shape, toggles, zoom, Metrics, Rect, OVER_THRESHOLD, }; /// A frame-to-frame step must move a channel by this much to count as a direction change (override /// with `--toggle-delta`). Above PNG/dither noise, below any real surface swap. /// /// **The pan rate matters more than this number.** A moving camera sweeps texture detail across /// every pixel, and detail is not monotone — at 8°/s (≈15 px/frame at 3200 px) the map saturates at /// 93% and says nothing. Keep the sweep **sub-pixel per frame** (≈0.2°/s) so ordinary surfaces /// barely move while a depth-comparison flip still swaps whole surfaces at full amplitude. const TOGGLE_MIN_DELTA: u8 = 4; /// Reversal counts are small integers, so the toggle map needs its own (much larger) gain than the /// 0..255 swings the envelope amplifies: at 60, three reversals saturate. const TOGGLE_AMPLIFY: u32 = 60; /// Gap (px) between the two halves of a side-by-side compose. const COMPOSE_GAP: u32 = 6; /// Tiles per row on a `hotspot` contact sheet — wide enough that a 24-frame burst reads as a few /// rows of adjacent frames rather than one unscannable ribbon. const STRIP_COLS: u32 = 6; /// Pixels of context kept around a hotspot crop (override with `--pad`). Enough to see what the /// toggling region borders — which is usually the whole point. const DEFAULT_PAD: u32 = 24; /// How many runs `hotspot` reports a time series for. Enough to see whether the top runs flip on the /// same frames (one cause) or independently (several), narrow enough to stay one screen. const SERIES_RUNS: usize = 4; /// Default amplification for the heatmap output (per-channel abs-diff ×N, clamped). const DEFAULT_AMPLIFY: u32 = 8; struct Opts { out: Option, fail: Option, amplify: u32, toggle_delta: u8, pad: u32, at: Option, rect: Option, scale: u32, } fn main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); let mut positional: Vec = Vec::new(); let mut opts = Opts { out: None, fail: None, amplify: DEFAULT_AMPLIFY, toggle_delta: TOGGLE_MIN_DELTA, pad: DEFAULT_PAD, at: None, rect: None, scale: 1, }; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--out" => opts.out = Some(PathBuf::from(next(&mut it, "--out")?)), "--fail" => { opts.fail = Some( next(&mut it, "--fail")? .parse() .context("--fail not a number")?, ) } "--toggle-delta" => { opts.toggle_delta = next(&mut it, "--toggle-delta")? .parse() .context("--toggle-delta not a 0..255 integer")? } "--amplify" => { opts.amplify = next(&mut it, "--amplify")? .parse() .context("--amplify not an integer")? } "--pad" => { opts.pad = next(&mut it, "--pad")? .parse() .context("--pad not an integer")? } "--at" => opts.at = Some(next(&mut it, "--at")?), "--rect" => opts.rect = Some(next(&mut it, "--rect")?), "--scale" => { opts.scale = next(&mut it, "--scale")? .parse() .context("--scale not an integer")? } "-h" | "--help" => { print_usage(); return Ok(()); } _ => positional.push(a.clone()), } } let Some((cmd, rest)) = positional.split_first() else { print_usage(); bail!("no subcommand given"); }; match cmd.as_str() { "diff" => { let [a, b] = two(rest, "diff")?; let m = diff_one( Path::new(a), Path::new(b), opts.out.as_deref(), opts.amplify, )?; println!("{:<28} {}", format!("{a} vs {b}"), fmt_metrics(&m)); if over_fail(&m, opts.fail) { bail!("MAE {:.3} exceeds --fail {:.3}", m.mae, opts.fail.unwrap()); } } "diff-dir" => { let [da, db] = two(rest, "diff-dir")?; let DirDiff { worst, unpaired } = diff_dir( Path::new(da), Path::new(db), opts.out.as_deref(), opts.amplify, )?; // Under `--fail` this is a gate, and a shot that never landed must sink it — see the // note in `diff_dir` (decision 0743). if opts.fail.is_some() && !unpaired.is_empty() { bail!( "{} unpaired image(s) — a shot is missing from one side, so this comparison is \ incomplete: {}", unpaired.len(), unpaired.join(", ") ); } if let Some((name, m)) = worst { if over_fail(&m, opts.fail) { bail!( "worst image {name:?} MAE {:.3} exceeds --fail {:.3}", m.mae, opts.fail.unwrap() ); } } } "flicker" => { let [dir] = one(rest, "flicker")?; let worst = flicker( Path::new(dir), opts.out.as_deref(), opts.amplify, opts.toggle_delta, )?; if over_fail(&worst, opts.fail) { bail!( "frame-to-frame MAE {:.3} exceeds --fail {:.3}", worst.mae, opts.fail.unwrap() ); } } "hotspot" => { let [dir] = one(rest, "hotspot")?; let out = opts .out .as_deref() .context("hotspot needs --out ")?; hotspot(Path::new(dir), out, opts.toggle_delta, opts.pad)?; } "series" => { let [dir] = one(rest, "series")?; let at = opts .at .as_deref() .context("series needs --at \",[;,…]\"")?; series(Path::new(dir), at)?; } "compose-dir" => { let [da, db] = two(rest, "compose-dir")?; let out = opts .out .as_deref() .context("compose-dir needs --out ")?; compose_dir(Path::new(da), Path::new(db), out)?; } "crop" => { let [img] = one(rest, "crop")?; let rect = opts .rect .as_deref() .context("crop needs --rect ,,, (source pixels)")?; let out = opts.out.as_deref().context("crop needs --out ")?; crop_cmd(Path::new(img), rect, opts.at.as_deref(), opts.scale, out)?; } other => { print_usage(); bail!("unknown subcommand {other:?}"); } } Ok(()) } /// What one named pixel *did*, frame by frame — the colour counterpart to the client's `WOW_DEPTH`. /// /// The aggregate readings (`flicker`, `hotspot`) answer "which pixels would not hold still" over a /// whole region, which is the right question when you are looking for a defect and the wrong one once /// you have found it. Correlating a renderer-side probe against the pixels needs the opposite: *this* /// pixel, *these* frames, no averaging. Run `WOW_DEPTH` and `--at` on the same coordinates and the two /// logs line up frame for frame — "the colour changed here but the depth did not" is a fact you cannot /// get from a region mean, because a run mean over 19 000 pixels hides which of them moved. /// /// It also guards the trap this exists inside: a burst that happens to contain **no** flip reads as a /// clean negative. The per-frame Δ makes an absent phenomenon obvious instead of invisible. fn series(dir: &Path, at: &str) -> Result<()> { let pixels = parse_at(at)?; let names = pngs(dir)?; if names.is_empty() { bail!("no PNGs in {}", dir.display()); } let frames: Vec = names .iter() .map(|n| load(&dir.join(n))) .collect::>()?; for (x, y) in pixels { let (w, h) = (frames[0].width(), frames[0].height()); if x >= w || y >= h { println!("({x}, {y}): outside the {w}x{h} frame"); continue; } println!("({x}, {y}):"); let mut prev: Option = None; for (i, f) in frames.iter().enumerate() { let p = f.get_pixel(x, y).0; let luma = 0.2126 * f64::from(p[0]) + 0.7152 * f64::from(p[1]) + 0.0722 * f64::from(p[2]); let delta = prev.map_or(String::new(), |q| format!(" Δ{:+.2}", luma - q)); println!( " {i:3} rgb({:3}, {:3}, {:3}) luma {luma:6.2}{delta}", p[0], p[1], p[2] ); prev = Some(luma); } } Ok(()) } /// Cut a window out of a capture, magnify it, and report what was actually cut — the vetted /// replacement for the ad-hoc `sips`/`ffmpeg` crop pipelines that kept minting false findings /// (`sips --cropOffset` takes (y, x) and silently ignores what it can't parse; a guessed ffmpeg /// window read "no eyes" off a frame the model had left). Three honesty rules: a rect that misses /// the frame entirely is an ERROR, never an edge-clamped guess; a rect that only partially fits is /// clamped *out loud*; and every pixel sample prints in SOURCE coordinates at source resolution, so /// nothing downstream does coordinate math on a zoomed image. fn crop_cmd(path: &Path, rect: &str, at: Option<&str>, scale: u32, out: &Path) -> Result<()> { let img = load(path)?; let (w, h) = img.dimensions(); let want = parse_rect(rect)?; if want.x0 >= w || want.y0 >= h { bail!( "--rect {rect} starts outside the {w}x{h} frame — re-aim; a silently moved window is \ exactly the mis-crop trap this tool exists to close" ); } let r = Rect { x0: want.x0, y0: want.y0, x1: want.x1.min(w), y1: want.y1.min(h), }; let clamped = r != want; let cut = crop(&img, r); let zoomed = zoom(&cut, scale); zoomed .save(out) .with_context(|| format!("writing {}", out.display()))?; println!( "{}: {w}x{h} → rect [{}..{}, {}..{}] ({}x{}){} ×{} → {} ({}x{})", path.display(), r.x0, r.x1, r.y0, r.y1, r.width(), r.height(), if clamped { " CLAMPED to frame" } else { "" }, scale.max(1), out.display(), zoomed.width(), zoomed.height(), ); // Samples: the rect's centre always, plus any --at points — all in source coordinates. let mut samples = vec![((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)]; if let Some(at) = at { samples.extend(parse_at(at)?); } for (x, y) in samples { if x >= w || y >= h { println!(" ({x}, {y}): outside the {w}x{h} frame"); continue; } let p = img.get_pixel(x, y).0; let luma = 0.2126 * f64::from(p[0]) + 0.7152 * f64::from(p[1]) + 0.0722 * f64::from(p[2]); let outside = if x < r.x0 || x >= r.x1 || y < r.y0 || y >= r.y1 { " (outside the crop)" } else { "" }; println!( " ({x}, {y}): rgb({:3}, {:3}, {:3}) luma {luma:.2}{outside}", p[0], p[1], p[2] ); } Ok(()) } /// `"x,y,w,h"` → the rect it names. Strict for the same reason as [`parse_at`]: a half-parsed /// window silently crops the wrong place, and a wrong crop reads as a finding. fn parse_rect(spec: &str) -> Result { let parts: Vec<&str> = spec.split(',').map(str::trim).collect(); let [x, y, w, h] = parts.as_slice() else { bail!("--rect wants \",,,\", got {spec:?}"); }; let parse = |v: &str, name: &str| -> Result { v.parse().with_context(|| format!("--rect {name} {v:?}")) }; let (x, y) = (parse(x, "x")?, parse(y, "y")?); let (w, h) = (parse(w, "w")?, parse(h, "h")?); if w == 0 || h == 0 { bail!("--rect wants a non-empty window, got {w}x{h}"); } Ok(Rect { x0: x, y0: y, x1: x + w, y1: y + h, }) } /// `"x,y;x,y"` → pixels. Strict: a typo here would silently report the wrong pixel's history. fn parse_at(spec: &str) -> Result> { spec.split(';') .filter(|s| !s.trim().is_empty()) .map(|pair| { let (x, y) = pair.split_once(',').context("--at wants \",;…\"")?; Ok(( x.trim() .parse() .with_context(|| format!("bad x in {pair:?}"))?, y.trim() .parse() .with_context(|| format!("bad y in {pair:?}"))?, )) }) .collect() } /// Stitch every `*.png` present in both dirs into `left | right` side-by-side images under `out`. fn compose_dir(da: &Path, db: &Path, out: &Path) -> Result<()> { let names: BTreeSet = pngs(da)?.intersection(&pngs(db)?).cloned().collect(); if names.is_empty() { bail!( "no common *.png files between {} and {}", da.display(), db.display() ); } std::fs::create_dir_all(out).ok(); for name in &names { let (l, r) = (load(&da.join(name))?, load(&db.join(name))?); let composed = compose_side_by_side(&l, &r, COMPOSE_GAP); let dst = out.join(name); composed .save(&dst) .with_context(|| format!("writing {}", dst.display()))?; println!("{} -> {}", name, dst.display()); } Ok(()) } /// Report how far a burst of adjacent frames moved: the frame-to-frame table, then the whole-stack /// envelope. Returns the worst consecutive-pair metrics (the `--fail` subject) — the envelope is a /// *stack* number and can exceed any single pair, so failing on the pair is the conservative gate. fn flicker(dir: &Path, out: Option<&Path>, amplify: u32, toggle_delta: u8) -> Result { let names: Vec = pngs(dir)?.into_iter().collect(); // BTreeSet — already shot order if names.len() < 2 { bail!( "{} holds {} *.png — a flicker burst needs at least 2 (WOW_LIVE_SHOT_COUNT=)", dir.display(), names.len() ); } let frames: Vec = names .iter() .map(|n| load(&dir.join(n))) .collect::>()?; let mut worst = Metrics { mae: 0.0, rmse: 0.0, max_delta: 0, pct_over: 0.0, }; for (pair, window) in frames.windows(2).enumerate() { let m = compare(&window[0], &window[1])?; println!( "{:<24} {}", format!("{} -> {}", names[pair], names[pair + 1]), fmt_metrics(&m) ); if m.mae > worst.mae { worst = m; } } let e = envelope(&frames, amplify)?; println!( "envelope of {} frames: max swing {:>3} mean {:>6.3} unstable pixels {:>6.2}%", frames.len(), e.max_swing, e.mean_swing, e.pct_unstable * 100.0 ); // The moving-camera reading. Printed always, because which of the two numbers is meaningful // depends on whether the camera was parked — and a burst does not carry that fact. let t = toggles(&frames, toggle_delta, TOGGLE_AMPLIFY)?; println!( "toggles of {} frames (delta {toggle_delta}): max reversals {:>3} toggling pixels {:>6.2}% \ (the moving-camera reading — a monotone sweep scores 0)", frames.len(), t.max_reversals, t.pct_toggling * 100.0 ); if let Some(out) = out { if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent).ok(); } e.image .save(out) .with_context(|| format!("writing envelope {}", out.display()))?; println!("envelope -> {}", out.display()); let toggle_out = sibling(out, "-toggle"); t.image .save(&toggle_out) .with_context(|| format!("writing toggle map {}", toggle_out.display()))?; println!("toggles -> {}", toggle_out.display()); } Ok(worst) } /// Follow the toggle map to the thing it found: measure the toggling pixels' **shape**, then crop /// the largest run out of every frame into one contact sheet. /// /// The map says *where*; this says *what kind*, and shows the frames that prove it. A run that fills /// its box and holds together is one surface blinking (visibility/culling); many thin runs are /// z-fighting resolving per fragment. Both read identically on the toggle percentage. fn hotspot(dir: &Path, out: &Path, toggle_delta: u8, pad: u32) -> Result<()> { let names: Vec = pngs(dir)?.into_iter().collect(); let frames: Vec = names .iter() .map(|n| load(&dir.join(n))) .collect::>()?; let t = toggles(&frames, toggle_delta, TOGGLE_AMPLIFY)?; let s = shape(&t); let (w, h) = t.image.dimensions(); println!( "toggling {:.2}% coherence {:.3} {} runs >= {} px ({} px scattered)", t.pct_toggling * 100.0, s.coherence, s.regions.len(), benilla_visual::MIN_REGION_PIXELS, s.scattered, ); for (i, r) in s.regions.iter().take(5).enumerate() { println!( " #{i} {:>8} px fill {:.2} box {}x{} at ({}, {})", r.pixels, r.fill(), r.bounds.width(), r.bounds.height(), r.bounds.x0, r.bounds.y0, ); // Pasteable straight into the in-game ray pick, which is the next question every time: // WOW_PICK="" names the surfaces at these exact pixels, front to back. let pick = r .samples() .iter() .map(|(x, y)| format!("{x},{y}")) .collect::>() .join(";"); println!(" WOW_PICK=\"{pick}\""); } let Some(biggest) = s.regions.first() else { bail!( "no toggling run of {} px or more — nothing to crop", benilla_visual::MIN_REGION_PIXELS ); }; // What the top runs are actually *doing*, frame by frame: re-shaded together, or edges moving. // Side by side, because whether they flip on the SAME steps is the difference between one global // cause and several local ones — and that is invisible in any single run's series. let series: Vec> = s .regions .iter() .take(SERIES_RUNS) .map(|r| r.steps(&frames)) .collect(); println!(" step-by-step per run — mean luma, its delta, and how much of the run agreed:"); for (i, _) in frames.windows(2).enumerate() { let cols: Vec = series .iter() .map(|s| { format!( "{:6.2}{:+7.2} @{:.2}", s[i].mean_from, s[i].mean_delta, s[i].agreement ) }) .collect(); println!(" {:>2} -> {:<2} {}", i, i + 1, cols.join(" ")); } // The two extremes of run #0, per channel — the *levels*, which is all this line is. Read the // ratios as description, never as a diagnosis: equal ratios rule out a scalar multiply and // nothing else, since an ADDED light moves the three channels by different factors too. The // reading that actually separates "one surface, re-lit" from "two surfaces" is the per-pixel // affine fit printed below it (`benilla_visual::relight`). let s0 = &series[0]; let lo = s0 .iter() .min_by(|a, b| a.mean_from.total_cmp(&b.mean_from)) .expect("a burst has at least one step"); let hi = s0 .iter() .max_by(|a, b| a.mean_from.total_cmp(&b.mean_from)) .expect("a burst has at least one step"); let ratio: Vec = (0..3) .map(|c| format!("{:.3}", hi.mean_rgb_from[c] / lo.mean_rgb_from[c].max(1e-9))) .collect(); println!( " #0 extremes: dim rgb ({:.1}, {:.1}, {:.1}) bright rgb ({:.1}, {:.1}, {:.1}) per-channel ratio {}", lo.mean_rgb_from[0], lo.mean_rgb_from[1], lo.mean_rgb_from[2], hi.mean_rgb_from[0], hi.mean_rgb_from[1], hi.mean_rgb_from[2], ratio.join(" / "), ); // Same surface re-lit, or a different surface? Fit each run's pixels across its own biggest // frame-to-frame step. High R² = the pattern survived the flip and only its gain/offset moved, // so it is one surface being shaded differently; low R² = the two frames show different // surfaces, whatever the means do. println!( " across each run's biggest step — is it one surface re-lit? (R² near 1 = yes), \ against its quietest step as the control:" ); for (i, r) in s.regions.iter().take(SERIES_RUNS).enumerate() { let (Some(flip), Some(quiet)) = ( benilla_visual::relight::biggest_step(r, &frames), benilla_visual::relight::quietest_step(r, &frames), ) else { continue; }; let fit = benilla_visual::relight::relight(r, &frames[flip], &frames[flip + 1]); let ctl = benilla_visual::relight::relight(r, &frames[quiet], &frames[quiet + 1]); let per: Vec = (0..3) .map(|c| { format!( "{:.2}x{:+.0} r²{:.3}", fit.gain[c], fit.offset[c], fit.r2[c] ) }) .collect(); // The control is what licenses the verdict: a low R² on the flip only means "different // surfaces" if the same pixels under the same pan fit tightly when they did NOT flip. let verdict = if ctl.worst_r2() < 0.9 { "no control — the pan alone decorrelates these pixels; fit says nothing" } else if fit.worst_r2() > 0.9 { "ONE SURFACE, re-lit" } else if fit.worst_r2() < 0.5 { "DIFFERENT SURFACES" } else { "inconclusive" }; println!( " #{i} flip {:>2}->{:<2} {} worst r² {:.3} | quiet {:>2}->{:<2} r² {:.3} => {verdict}", flip, flip + 1, per.join(" "), fit.worst_r2(), quiet, quiet + 1, ctl.worst_r2(), ); } let rect = biggest.bounds.padded(pad, w, h); // The toggle map goes in as the first tile, so the sheet carries its own legend: this is the // region, and here is what the frames did inside it. let mut tiles = vec![crop(&t.image, rect)]; tiles.extend(frames.iter().map(|f| crop(f, rect))); if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent).ok(); } contact_strip(&tiles, STRIP_COLS, COMPOSE_GAP) .save(out) .with_context(|| format!("writing hotspot strip {}", out.display()))?; println!( "crop {}x{} at ({}, {}) x {} tiles -> {}", rect.width(), rect.height(), rect.x0, rect.y0, tiles.len(), out.display() ); Ok(()) } /// `foo.png` + `-toggle` → `foo-toggle.png`. The two maps answer the same question under opposite /// camera conditions, so they are written as a pair rather than behind a second flag nobody would /// remember to pass. fn sibling(out: &Path, suffix: &str) -> PathBuf { let ext = out.extension().and_then(|e| e.to_str()).unwrap_or("png"); let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or("out"); out.with_file_name(format!("{stem}{suffix}.{ext}")) } fn next(it: &mut std::slice::Iter, flag: &str) -> Result { it.next() .cloned() .with_context(|| format!("{flag} needs a value")) } fn two<'a>(rest: &'a [String], cmd: &str) -> Result<[&'a str; 2]> { match rest { [a, b] => Ok([a, b]), _ => bail!("{cmd} needs exactly two paths, got {}", rest.len()), } } fn one<'a>(rest: &'a [String], cmd: &str) -> Result<[&'a str; 1]> { match rest { [a] => Ok([a]), _ => bail!("{cmd} needs exactly one path, got {}", rest.len()), } } fn over_fail(m: &Metrics, fail: Option) -> bool { fail.is_some_and(|t| m.mae > t) } /// Load an image as RGB (dropping any alpha — capture windows are opaque). fn load(path: &Path) -> Result { Ok(image::open(path) .with_context(|| format!("opening {}", path.display()))? .to_rgb8()) } fn diff_one(a: &Path, b: &Path, out: Option<&Path>, amplify: u32) -> Result { let (ia, ib) = (load(a)?, load(b)?); let m = compare(&ia, &ib)?; if let Some(out) = out { if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent).ok(); } diff_image(&ia, &ib, amplify)? .save(out) .with_context(|| format!("writing diff {}", out.display()))?; } Ok(m) } /// Compare every `*.png` present in both dirs (by file name). Returns the worst (highest-MAE) result. /// What one `diff-dir` run found: the worst pair by MAE, and every image present on only ONE side. struct DirDiff { worst: Option<(String, Metrics)>, /// Files with no counterpart, so not diffed at all — see the note in [`diff_dir`]. unpaired: Vec, } fn diff_dir(da: &Path, db: &Path, out: Option<&Path>, amplify: u32) -> Result { let (a, b) = (pngs(da)?, pngs(db)?); let names: BTreeSet = a.intersection(&b).cloned().collect(); if names.is_empty() { bail!( "no common *.png files between {} and {}", da.display(), db.display() ); } // UNPAIRED shots are ANNOUNCED, never skipped in silence. Pairing on the intersection alone is // how a sweep reports all-green with a shot missing: on 2026-07-28 one `water-night` capture // exited 0 without writing its PNG, and `selfcheck` diffed the other eight and passed clean. A // gate that quietly narrows its own scope is worse than no gate. Reported always; fatal to the // CALLER when `--fail` is in force, i.e. whenever this is being used as a gate (decision 0743). let unpaired: Vec = a .difference(&b) .map(|n| format!("{}/{n}", da.display())) .chain(b.difference(&a).map(|n| format!("{}/{n}", db.display()))) .collect(); for u in &unpaired { eprintln!("UNPAIRED (no counterpart — NOT diffed): {u}"); } if let Some(out) = out { std::fs::create_dir_all(out).ok(); } let mut worst: Option<(String, Metrics)> = None; for name in &names { let diff_out = out.map(|o| o.join(name)); let m = diff_one(&da.join(name), &db.join(name), diff_out.as_deref(), amplify)?; println!("{:<24} {}", name, fmt_metrics(&m)); if worst.as_ref().is_none_or(|(_, w)| m.mae > w.mae) { worst = Some((name.clone(), m)); } } if let Some((name, m)) = &worst { println!("worst: {name} (MAE {:.3})", m.mae); } Ok(DirDiff { worst, unpaired }) } fn pngs(dir: &Path) -> Result> { let mut set = BTreeSet::new(); for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {}", dir.display()))? { let path = entry?.path(); if path .extension() .is_some_and(|e| e.eq_ignore_ascii_case("png")) { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { set.insert(name.to_string()); } } } Ok(set) } fn fmt_metrics(m: &Metrics) -> String { format!( "MAE {:>6.3} RMSE {:>6.3} max {:>3} >{}: {:>6.2}%", m.mae, m.rmse, m.max_delta, OVER_THRESHOLD, m.pct_over * 100.0 ) } fn print_usage() { eprintln!( "benilla-visual — diff Phase-5 visual-harness captures\n\ \n\ USAGE:\n \ benilla-visual diff [--out ] [--fail ] [--amplify ]\n \ benilla-visual diff-dir [--out ] [--fail ] [--amplify ]\n \ benilla-visual flicker [--out ] [--fail ] [--amplify ] [--toggle-delta ]\n \ benilla-visual hotspot --out [--toggle-delta ] [--pad ]\n \ benilla-visual series --at \",[;,…]\"\n \ benilla-visual compose-dir --out (side-by-side `a | b` per image)\n \ benilla-visual crop --rect \",,,\" --out [--scale ] [--at \",;…\"]\n\ \n\ flicker reads a WOW_LIVE_SHOT_COUNT burst (adjacent frames) in shot order and reports both\n\ the parked reading (envelope) and the moving-camera one (toggles).\n\ hotspot follows the toggle map: it measures the toggling pixels' shape — one solid run is a\n\ surface blinking, many thin ones are z-fighting — and crops the largest into a contact sheet.\n\ series drops the averaging: one named pixel's rgb/luma per frame, to line up against the\n\ client's WOW_DEPTH log at the same coordinates.\n\ crop cuts a window for close reading (nearest-neighbour zoom only, samples printed in\n\ SOURCE coordinates) — use it instead of sips/ffmpeg pipelines, which have minted false\n\ findings (sips --cropOffset is (y, x) and silently ignores what it can't parse).\n\ Exits non-zero if any image's MAE exceeds --fail." ); } #[cfg(test)] mod tests { use super::*; #[test] fn rect_spec_is_strict() { // "x,y,w,h" and nothing else — a half-parsed window crops the wrong place silently. let r = parse_rect("10, 20, 30, 40").unwrap(); assert_eq!((r.x0, r.y0, r.x1, r.y1), (10, 20, 40, 60)); assert!(parse_rect("10,20,30").is_err()); assert!(parse_rect("10,20,30,40,50").is_err()); assert!(parse_rect("10,20,0,40").is_err()); assert!(parse_rect("a,20,30,40").is_err()); } }