Part of #41: the comments of the thirteen small crates (`benilla-adt`, `-blp`, `-buildstamp`, `-bytes`, `-dbc`, `-m2`, `-mpq`, `-srp`, `-visual`, `-wdt`, `-wmo`, `-worldview` and `benilla`) rewritten to the rule in `docs/METHOD.md`: what the code does and the reference fact behind it, in a line. 2,176 comment lines become 1,029. - The citations that were only a private record number go first, mechanically, each edit shown to be a pure removal. - The rewrite corrects the comments that stated wrong facts: the M2 header's selection-ring claim, the texture-transform and MCNK impassable-flag notes, a DBC reader note that said short records zero-pad (they do not, #17), and about twenty more found on the way. The SRP handshake's redraw and redial are marked as a deviation, with the measured reason. - Verified: every file strips to identical code, and `cargo fmt --check` is clean. A second reader compared every file old against new and restored the facts the rewrite had dropped. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1106 lines
40 KiB
Rust
1106 lines
40 KiB
Rust
//! `benilla-visual`: diff captures, and read flicker and motion out of a burst of frames; `--help`
|
||
//! lists the subcommands. The regression loop: capture baselines, change the renderer, re-capture,
|
||
//! then `diff-dir baseline candidate --out diff --fail 1.5`.
|
||
|
||
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,
|
||
};
|
||
|
||
/// The smallest step that counts as a direction change (`--toggle-delta`): above PNG and dither
|
||
/// noise, below a surface swap. The pan rate matters more: keep a sweep sub-pixel per frame (about
|
||
/// 0.2°/s), since at 8°/s texture detail saturates the map.
|
||
const TOGGLE_MIN_DELTA: u8 = 4;
|
||
|
||
/// The toggle map's gain: reversal counts are small, and at 60 three of them 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.
|
||
const STRIP_COLS: u32 = 6;
|
||
|
||
/// Pixels of context around a hotspot crop (`--pad`), enough to see what the run borders.
|
||
const DEFAULT_PAD: u32 = 24;
|
||
|
||
/// Runs `hotspot` prints a series for: enough to see whether the top runs flip together.
|
||
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<PathBuf>,
|
||
fail: Option<f64>,
|
||
amplify: u32,
|
||
toggle_delta: u8,
|
||
pad: u32,
|
||
at: Option<String>,
|
||
rect: Option<String>,
|
||
scale: u32,
|
||
}
|
||
|
||
fn main() -> Result<()> {
|
||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||
let mut positional: Vec<String> = 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 missing shot fails it.
|
||
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 <strip.png>")?;
|
||
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 \"<x>,<y>[;<x>,<y>…]\"")?;
|
||
series(Path::new(dir), at)?;
|
||
}
|
||
"flow" => {
|
||
let [dir] = one(rest, "flow")?;
|
||
flow(Path::new(dir), opts.rect.as_deref())?;
|
||
}
|
||
"edge" => {
|
||
let [dir] = one(rest, "edge")?;
|
||
let at = opts
|
||
.at
|
||
.as_deref()
|
||
.context("edge needs --at \"<x0>,<y>,<x1>[;…]\"")?;
|
||
edge(Path::new(dir), at)?;
|
||
}
|
||
"compose-dir" => {
|
||
let [da, db] = two(rest, "compose-dir")?;
|
||
let out = opts
|
||
.out
|
||
.as_deref()
|
||
.context("compose-dir needs --out <dir>")?;
|
||
compose_dir(Path::new(da), Path::new(db), out)?;
|
||
}
|
||
"stat" => {
|
||
let [img] = one(rest, "stat")?;
|
||
stat_cmd(Path::new(img), opts.rect.as_deref())?;
|
||
}
|
||
"crop" => {
|
||
let [img] = one(rest, "crop")?;
|
||
let rect = opts
|
||
.rect
|
||
.as_deref()
|
||
.context("crop needs --rect <x>,<y>,<w>,<h> (source pixels)")?;
|
||
let out = opts.out.as_deref().context("crop needs --out <crop.png>")?;
|
||
crop_cmd(Path::new(img), rect, opts.at.as_deref(), opts.scale, out)?;
|
||
}
|
||
other => {
|
||
print_usage();
|
||
bail!("unknown subcommand {other:?}");
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// One named pixel's colour frame by frame, to line up against the client's `WOW_DEPTH` log at the
|
||
/// same coordinates; the per-frame delta shows a burst that caught no flip at all.
|
||
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<image::RgbImage> = names
|
||
.iter()
|
||
.map(|n| load(&dir.join(n)))
|
||
.collect::<Result<_>>()?;
|
||
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<f64> = 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(())
|
||
}
|
||
|
||
/// Per-channel min, mean and max over a rect or the whole frame: an edge showing the clear colour
|
||
/// instead of art reads `min == max` on every channel, which a region of art never does.
|
||
fn stat_cmd(path: &Path, rect: Option<&str>) -> Result<()> {
|
||
let img = load(path)?;
|
||
let (w, h) = img.dimensions();
|
||
let r = match rect {
|
||
Some(spec) => {
|
||
let want = parse_rect(spec)?;
|
||
if want.x0 >= w || want.y0 >= h {
|
||
bail!("--rect {spec} starts outside the {w}x{h} frame");
|
||
}
|
||
Rect {
|
||
x0: want.x0,
|
||
y0: want.y0,
|
||
x1: want.x1.min(w),
|
||
y1: want.y1.min(h),
|
||
}
|
||
}
|
||
None => Rect {
|
||
x0: 0,
|
||
y0: 0,
|
||
x1: w,
|
||
y1: h,
|
||
},
|
||
};
|
||
let mut min = [u8::MAX; 3];
|
||
let mut max = [u8::MIN; 3];
|
||
let mut sum = [0u64; 3];
|
||
for y in r.y0..r.y1 {
|
||
for x in r.x0..r.x1 {
|
||
let p = img.get_pixel(x, y).0;
|
||
for c in 0..3 {
|
||
min[c] = min[c].min(p[c]);
|
||
max[c] = max[c].max(p[c]);
|
||
sum[c] += u64::from(p[c]);
|
||
}
|
||
}
|
||
}
|
||
let n = u64::from(r.width()) * u64::from(r.height());
|
||
let mean = sum.map(|s| s as f64 / n as f64);
|
||
let flat = min == max;
|
||
println!(
|
||
"{}: {w}x{h} rect [{}..{}, {}..{}] ({} px) min {:?} mean [{:.1}, {:.1}, {:.1}] max {:?} {}",
|
||
path.display(),
|
||
r.x0,
|
||
r.x1,
|
||
r.y0,
|
||
r.y1,
|
||
n,
|
||
min,
|
||
mean[0],
|
||
mean[1],
|
||
mean[2],
|
||
max,
|
||
if flat { "FLAT" } else { "varied" }
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
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 and any `--at` points, 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, since a half-parsed window crops the wrong place.
|
||
fn parse_rect(spec: &str) -> Result<Rect> {
|
||
let parts: Vec<&str> = spec.split(',').map(str::trim).collect();
|
||
let [x, y, w, h] = parts.as_slice() else {
|
||
bail!("--rect wants \"<x>,<y>,<w>,<h>\", got {spec:?}");
|
||
};
|
||
let parse = |v: &str, name: &str| -> Result<u32> {
|
||
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, since a typo would report the wrong pixel's history.
|
||
fn parse_at(spec: &str) -> Result<Vec<(u32, u32)>> {
|
||
spec.split(';')
|
||
.filter(|s| !s.trim().is_empty())
|
||
.map(|pair| {
|
||
let (x, y) = pair.split_once(',').context("--at wants \"<x>,<y>;…\"")?;
|
||
Ok((
|
||
x.trim()
|
||
.parse()
|
||
.with_context(|| format!("bad x in {pair:?}"))?,
|
||
y.trim()
|
||
.parse()
|
||
.with_context(|| format!("bad y in {pair:?}"))?,
|
||
))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The sub-pixel motion ruler: one least-squares (Lucas-Kanade) displacement per frame pair over
|
||
/// the whole window, so thousands of gradients average the 8-bit floor away and a render that
|
||
/// advances in steps reads as steps. Columns are the displacement, its magnitude and the second
|
||
/// difference; a stalled frame moves under a fifth of the median step. It assumes constant
|
||
/// brightness: for a shaded, deforming body use `edge`.
|
||
fn flow(dir: &Path, rect: Option<&str>) -> Result<()> {
|
||
let names = pngs(dir)?;
|
||
if names.len() < 3 {
|
||
bail!(
|
||
"flow wants at least 3 frames, {} has {}",
|
||
dir.display(),
|
||
names.len()
|
||
);
|
||
}
|
||
let first = load(&dir.join(names.iter().next().expect("non-empty")))?;
|
||
let (w, h) = first.dimensions();
|
||
// A named rect is clamped out loud, never silently; the solve skips the one-pixel border.
|
||
let win = match rect {
|
||
Some(spec) => {
|
||
let want = parse_rect(spec)?;
|
||
let got = Rect {
|
||
x0: want.x0.min(w.saturating_sub(1)),
|
||
y0: want.y0.min(h.saturating_sub(1)),
|
||
x1: want.x1.min(w),
|
||
y1: want.y1.min(h),
|
||
};
|
||
if got.x0 >= got.x1 || got.y0 >= got.y1 {
|
||
bail!("--rect {spec:?} lies outside the {w}x{h} frame");
|
||
}
|
||
if got != want {
|
||
println!(
|
||
"flow: --rect clamped to [{}..{}, {}..{}]",
|
||
got.x0, got.x1, got.y0, got.y1
|
||
);
|
||
}
|
||
got
|
||
}
|
||
None => Rect {
|
||
x0: 0,
|
||
y0: 0,
|
||
x1: w,
|
||
y1: h,
|
||
},
|
||
};
|
||
let (ww, wh) = ((win.x1 - win.x0) as usize, (win.y1 - win.y0) as usize);
|
||
if ww < 3 || wh < 3 {
|
||
bail!("flow wants a window at least 3x3, got {ww}x{wh}");
|
||
}
|
||
println!(
|
||
"flow: {} frames, {w}x{h}, window [{}..{}, {}..{}] ({ww}x{wh} = {} px)",
|
||
names.len(),
|
||
win.x0,
|
||
win.x1,
|
||
win.y0,
|
||
win.y1,
|
||
ww * wh
|
||
);
|
||
println!(" frame dx dy |d| |d2|");
|
||
let mut prev = luma_window(&first, &win);
|
||
let mut steps: Vec<(f64, f64)> = Vec::new();
|
||
for (i, name) in names.iter().enumerate().skip(1) {
|
||
let cur = luma_window(&load(&dir.join(name))?, &win);
|
||
let Some((dx, dy)) = lucas_kanade(&prev, &cur, ww, wh) else {
|
||
println!(" {i:5} (no gradient in the window — flow is undefined here)");
|
||
prev = cur;
|
||
continue;
|
||
};
|
||
let d2 = steps
|
||
.last()
|
||
.map(|&(px, py)| (dx - px).hypot(dy - py))
|
||
.unwrap_or(f64::NAN);
|
||
println!(
|
||
" {i:5} {dx:+8.4} {dy:+8.4} {:7.4} {:8.4}",
|
||
dx.hypot(dy),
|
||
d2
|
||
);
|
||
steps.push((dx, dy));
|
||
prev = cur;
|
||
}
|
||
let mags: Vec<f64> = steps.iter().map(|&(x, y)| x.hypot(y)).collect();
|
||
let curv: Vec<f64> = steps
|
||
.windows(2)
|
||
.map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1))
|
||
.collect();
|
||
if mags.is_empty() {
|
||
return Ok(());
|
||
}
|
||
let med = |v: &mut Vec<f64>| -> f64 {
|
||
v.sort_by(f64::total_cmp);
|
||
v[v.len() / 2]
|
||
};
|
||
let med_mag = med(&mut mags.clone());
|
||
let stalled = mags.iter().filter(|m| **m < 0.2 * med_mag).count();
|
||
println!(
|
||
" --- steps: median |d| {med_mag:.4} px max |d| {:.4} stalled (<20% of median) {stalled}/{}",
|
||
mags.iter().copied().fold(0.0, f64::max),
|
||
mags.len()
|
||
);
|
||
if !curv.is_empty() {
|
||
let med_c = med(&mut curv.clone());
|
||
println!(
|
||
" --- curvature: median |d2| {med_c:.4} px max |d2| {:.4} ratio |d2|/|d| {:.3}",
|
||
curv.iter().copied().fold(0.0, f64::max),
|
||
med_c / med_mag.max(f64::MIN_POSITIVE)
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// One frame's luma over `win`, row-major; `f32` halves the working set of a 3200x1800 frame.
|
||
fn luma_window(img: &image::RgbImage, win: &Rect) -> Vec<f32> {
|
||
let mut out = Vec::with_capacity(((win.x1 - win.x0) * (win.y1 - win.y0)) as usize);
|
||
for y in win.y0..win.y1 {
|
||
for x in win.x0..win.x1 {
|
||
let p = img.get_pixel(x, y).0;
|
||
out.push(
|
||
0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2]),
|
||
);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The `(dx, dy)` that best explains `b` as `a` shifted, over every interior pixel; `None` when
|
||
/// the normal matrix is singular, as over a flat sky.
|
||
fn lucas_kanade(a: &[f32], b: &[f32], w: usize, h: usize) -> Option<(f64, f64)> {
|
||
let (mut sxx, mut sxy, mut syy, mut sxt, mut syt) = (0.0f64, 0.0, 0.0, 0.0, 0.0);
|
||
for y in 1..h - 1 {
|
||
for x in 1..w - 1 {
|
||
let i = y * w + x;
|
||
let ix = 0.5 * f64::from(a[i + 1] - a[i - 1]);
|
||
let iy = 0.5 * f64::from(a[i + w] - a[i - w]);
|
||
let it = f64::from(b[i] - a[i]);
|
||
sxx += ix * ix;
|
||
sxy += ix * iy;
|
||
syy += iy * iy;
|
||
sxt += ix * it;
|
||
syt += iy * it;
|
||
}
|
||
}
|
||
let det = sxx * syy - sxy * sxy;
|
||
// Relative, since the normal matrix grows with the window.
|
||
if det <= 1e-12 * (sxx * syy).max(f64::MIN_POSITIVE) {
|
||
return None;
|
||
}
|
||
Some(((sxy * syt - syy * sxt) / det, (sxy * sxt - sxx * syt) / det))
|
||
}
|
||
|
||
fn compose_dir(da: &Path, db: &Path, out: &Path) -> Result<()> {
|
||
let names: BTreeSet<String> = 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(())
|
||
}
|
||
|
||
/// Print a burst's frame-to-frame table, envelope and toggles; the worst pair's metrics are the
|
||
/// `--fail` subject, since the envelope can exceed any single pair.
|
||
fn flicker(dir: &Path, out: Option<&Path>, amplify: u32, toggle_delta: u8) -> Result<Metrics> {
|
||
let names: Vec<String> = pngs(dir)?.into_iter().collect(); // a BTreeSet: already shot order
|
||
if names.len() < 2 {
|
||
bail!(
|
||
"{} holds {} *.png — a flicker burst needs at least 2 (WOW_LIVE_SHOT_COUNT=<n>)",
|
||
dir.display(),
|
||
names.len()
|
||
);
|
||
}
|
||
let frames: Vec<image::RgbImage> = names
|
||
.iter()
|
||
.map(|n| load(&dir.join(n)))
|
||
.collect::<Result<_>>()?;
|
||
|
||
let mut worst = Metrics {
|
||
mae: 0.0,
|
||
rmse: 0.0,
|
||
max_delta: 0,
|
||
pct_over: 0.0,
|
||
changed: 0,
|
||
worst_at: (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
|
||
);
|
||
// Printed always: a burst does not record whether the camera was parked.
|
||
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)
|
||
}
|
||
|
||
/// Measure the toggling pixels' shape, print the top runs' series and relight fits, and crop the
|
||
/// largest run out of every frame into one contact sheet.
|
||
fn hotspot(dir: &Path, out: &Path, toggle_delta: u8, pad: u32) -> Result<()> {
|
||
let names: Vec<String> = pngs(dir)?.into_iter().collect();
|
||
let frames: Vec<image::RgbImage> = names
|
||
.iter()
|
||
.map(|n| load(&dir.join(n)))
|
||
.collect::<Result<_>>()?;
|
||
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,
|
||
);
|
||
// For the ray pick: `WOW_PICK` names the surfaces at these pixels, front to back.
|
||
let pick = r
|
||
.samples()
|
||
.iter()
|
||
.map(|(x, y)| format!("{x},{y}"))
|
||
.collect::<Vec<_>>()
|
||
.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
|
||
);
|
||
};
|
||
// The top runs side by side: flipping on the same steps means one cause, not several.
|
||
let series: Vec<Vec<benilla_visual::Step>> = 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<String> = 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(" "));
|
||
}
|
||
// Run #0's extremes per channel, a description only: equal ratios rule out a scalar multiply
|
||
// and nothing else. The relight fit below is the diagnosis.
|
||
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<String> = (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(" / "),
|
||
);
|
||
// Each run fitted across its biggest step: high R² is one surface re-lit, low R² two surfaces.
|
||
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<String> = (0..3)
|
||
.map(|c| {
|
||
format!(
|
||
"{:.2}x{:+.0} r²{:.3}",
|
||
fit.gain[c], fit.offset[c], fit.r2[c]
|
||
)
|
||
})
|
||
.collect();
|
||
// A low R² on the flip means two surfaces only if the quiet step fits tightly.
|
||
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 is the first tile, the sheet's own legend.
|
||
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`.
|
||
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<String>, flag: &str) -> Result<String> {
|
||
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<f64>) -> bool {
|
||
fail.is_some_and(|t| m.mae > t)
|
||
}
|
||
|
||
/// One silhouette's sub-pixel position along a scanline, per frame: where the row crosses the
|
||
/// midpoint of its own darkest and brightest samples, so a uniform brightness change leaves it in
|
||
/// place and only a geometric shift moves it.
|
||
fn edge(dir: &Path, at: &str) -> Result<()> {
|
||
let names = pngs(dir)?;
|
||
if names.len() < 3 {
|
||
bail!(
|
||
"edge wants at least 3 frames, {} has {}",
|
||
dir.display(),
|
||
names.len()
|
||
);
|
||
}
|
||
let mut lines: Vec<(u32, u32, u32)> = Vec::new();
|
||
for spec in at.split(';').filter(|s| !s.trim().is_empty()) {
|
||
let p: Vec<u32> = spec
|
||
.trim()
|
||
.split(',')
|
||
.map(|v| {
|
||
v.trim()
|
||
.parse::<u32>()
|
||
.context("edge --at wants <x0>,<y>,<x1>")
|
||
})
|
||
.collect::<Result<_>>()?;
|
||
let [x0, y, x1] = p[..] else {
|
||
bail!("edge --at wants <x0>,<y>,<x1>, got {spec:?}");
|
||
};
|
||
if x1 <= x0 + 1 {
|
||
bail!("edge scanline {spec:?} needs x1 > x0+1");
|
||
}
|
||
lines.push((x0, y, x1));
|
||
}
|
||
for &(x0, y, x1) in &lines {
|
||
println!("scanline y={y} x {x0}..{x1}");
|
||
let mut pos: Vec<f64> = Vec::new();
|
||
for name in &names {
|
||
let img = load(&dir.join(name))?;
|
||
if y >= img.height() || x1 >= img.width() {
|
||
bail!(
|
||
"scanline ({x0},{y},{x1}) outside {}x{}",
|
||
img.width(),
|
||
img.height()
|
||
);
|
||
}
|
||
let row: Vec<f64> = (x0..=x1)
|
||
.map(|x| {
|
||
let p = img.get_pixel(x, y).0;
|
||
0.2126 * f64::from(p[0]) + 0.7152 * f64::from(p[1]) + 0.0722 * f64::from(p[2])
|
||
})
|
||
.collect();
|
||
let lo = row.iter().copied().fold(f64::INFINITY, f64::min);
|
||
let hi = row.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||
// Too little contrast to call an edge: report it, never invent a position.
|
||
if hi - lo < 12.0 {
|
||
pos.push(f64::NAN);
|
||
continue;
|
||
}
|
||
let mid = 0.5 * (lo + hi);
|
||
let mut found = f64::NAN;
|
||
for i in 0..row.len() - 1 {
|
||
let (a, b) = (row[i] - mid, row[i + 1] - mid);
|
||
if (a <= 0.0 && b > 0.0) || (a >= 0.0 && b < 0.0) {
|
||
found = f64::from(x0) + i as f64 + a / (a - b);
|
||
break;
|
||
}
|
||
}
|
||
pos.push(found);
|
||
}
|
||
let good = pos.iter().filter(|p| p.is_finite()).count();
|
||
for (i, p) in pos.iter().enumerate().take(28) {
|
||
let d = if i == 0 { f64::NAN } else { p - pos[i - 1] };
|
||
println!(" {i:4} x={p:10.4} d={d:+8.4}");
|
||
}
|
||
let d1: Vec<f64> = pos
|
||
.windows(2)
|
||
.map(|w| (w[1] - w[0]).abs())
|
||
.filter(|v| v.is_finite())
|
||
.collect();
|
||
let d2: Vec<f64> = pos
|
||
.windows(3)
|
||
.map(|w| (w[2] - 2.0 * w[1] + w[0]).abs())
|
||
.filter(|v| v.is_finite())
|
||
.collect();
|
||
if d1.is_empty() || d2.is_empty() {
|
||
println!(
|
||
" no usable edge on {}/{} frames",
|
||
names.len() - good,
|
||
names.len()
|
||
);
|
||
continue;
|
||
}
|
||
let med = |mut v: Vec<f64>| {
|
||
v.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
|
||
v[v.len() / 2]
|
||
};
|
||
let (m1, m2) = (med(d1.clone()), med(d2.clone()));
|
||
let mx = d1.iter().copied().fold(0.0, f64::max);
|
||
println!(
|
||
" frames with an edge {good}/{} median |d| {m1:.4} px max |d| {mx:.4} px",
|
||
names.len()
|
||
);
|
||
println!(
|
||
" median |d2| {m2:.4} px roughness |d2|/|d| {:.3} (a smooth curve at 60 Hz is <<1)",
|
||
m2 / m1.max(1e-9)
|
||
);
|
||
let stalled = d1.iter().filter(|&&v| v < 0.2 * m1).count();
|
||
println!(" stalled steps (<20% of median): {stalled}/{}", d1.len());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn load(path: &Path) -> Result<image::RgbImage> {
|
||
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<Metrics> {
|
||
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)
|
||
}
|
||
|
||
/// What one `diff-dir` run found: the worst pair by MAE, and every image on only one side.
|
||
struct DirDiff {
|
||
worst: Option<(String, Metrics)>,
|
||
/// Files with no counterpart, so not diffed.
|
||
unpaired: Vec<String>,
|
||
}
|
||
|
||
fn diff_dir(da: &Path, db: &Path, out: Option<&Path>, amplify: u32) -> Result<DirDiff> {
|
||
let (a, b) = (pngs(da)?, pngs(db)?);
|
||
let names: BTreeSet<String> = 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, so a capture that wrote no PNG
|
||
// cannot let the rest pass clean; the caller fails on them under `--fail`.
|
||
let unpaired: Vec<String> = 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<BTreeSet<String>> {
|
||
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 {
|
||
// `px` and the worst pixel's coordinate tell an MSAA tie from a render change: a handful of
|
||
// pixels at the same spot every build is a tie.
|
||
let where_ = if m.changed == 0 {
|
||
String::new()
|
||
} else {
|
||
format!(" @{},{}", m.worst_at.0, m.worst_at.1)
|
||
};
|
||
format!(
|
||
"MAE {:>6.3} RMSE {:>6.3} max {:>3}{:<12} px {:>8} >{}: {:>6.2}%",
|
||
m.mae,
|
||
m.rmse,
|
||
m.max_delta,
|
||
where_,
|
||
m.changed,
|
||
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 <a.png> <b.png> [--out <diff.png>] [--fail <mae>] [--amplify <n>]\n \
|
||
benilla-visual diff-dir <dir_a> <dir_b> [--out <diff_dir>] [--fail <mae>] [--amplify <n>]\n \
|
||
benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>] [--toggle-delta <n>]\n \
|
||
benilla-visual hotspot <burst_dir> --out <strip.png> [--toggle-delta <n>] [--pad <n>]\n \
|
||
benilla-visual series <burst_dir> --at \"<x>,<y>[;<x>,<y>…]\"\n \
|
||
benilla-visual flow <burst_dir> [--rect \"<x>,<y>,<w>,<h>\"]\n \
|
||
benilla-visual edge <burst_dir> --at \"<x0>,<y>,<x1>[;…]\"\n \
|
||
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n \
|
||
benilla-visual stat <img.png> [--rect \"<x>,<y>,<w>,<h>\"]\n\
|
||
benilla-visual crop <img.png> --rect \"<x>,<y>,<w>,<h>\" --out <crop.png> [--scale <n>] [--at \"<x>,<y>;…\"]\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\
|
||
edge NOISE FLOOR: about +/-0.5 px per frame on real content. VALIDATED against a provably
|
||
smooth 0.5 deg/s camera pan over static geometry (true motion 0.28 px/frame, monotone): edge
|
||
reported steps of -0.241 +0.212 +0.998 +0.732 -0.749 — NEGATIVE steps on a monotone pan. Its
|
||
MEAN is right, its per-frame value is not. So it can only resolve discrete events well above
|
||
~1 px (the attachment staircase's 0.83-1.1 px hop clears it; a 0.1-0.2 px breathing motion does
|
||
not, and a roughness statistic computed at that scale is measuring this floor, not the render).
|
||
edge tracks a SILHOUETTE's sub-pixel position along one scanline, per frame. Unlike flow it
|
||
does not assume brightness constancy — it renormalises to the scanline's own light/dark ends
|
||
every frame, so a shaded, deforming surface (skin, cloth) whose lighting changes as it moves
|
||
still reads its GEOMETRY. flow is invalid on exactly that content: on a run where the palette
|
||
was provably 33x rougher it reported *smoother*, because Lucas-Kanade read the shading change
|
||
as motion. Use edge for a body, flow only for rigid, uniformly-lit subjects.
|
||
flow measures MOTION rather than change: one least-squares sub-pixel displacement per frame\n\
|
||
pair over the whole window, so a render that advances in steps reads as steps and one that\n\
|
||
advances smoothly reads smooth — the question a per-pixel delta cannot answer.\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() {
|
||
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());
|
||
}
|
||
}
|