From 4be2355174c6d9eefff94bbee082c4c26f198ffd Mon Sep 17 00:00:00 2001 From: Fini Date: Thu, 2 Jul 2026 21:21:32 +0800 Subject: [PATCH] =?UTF-8?q?chore(render-parity):=20SSIM/bbox/=CE=94E=20per?= =?UTF-8?q?-node=20diff=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff_nodes.py computes windowed-SSIM + bbox-hit + ΔE76 + color_de per node and classifies each as PASS / PAINT / LAYOUT; prep_parity.sh drives pen2op→.op→ render-shots vs Pencil export_nodes baselines. --- .../__pycache__/diff_nodes.cpython-314.pyc | Bin 0 -> 11759 bytes scripts/render-parity/diff_nodes.py | 210 ++++++++++++++++++ scripts/render-parity/prep_parity.sh | 38 ++++ 3 files changed, 248 insertions(+) create mode 100644 scripts/render-parity/__pycache__/diff_nodes.cpython-314.pyc create mode 100644 scripts/render-parity/diff_nodes.py create mode 100644 scripts/render-parity/prep_parity.sh diff --git a/scripts/render-parity/__pycache__/diff_nodes.cpython-314.pyc b/scripts/render-parity/__pycache__/diff_nodes.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb9aac9cc99a01e0ff653253166184a8b385add2 GIT binary patch literal 11759 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.png exists on both sides, +so parity is measured per node. Metric is perceptual (the agreed bar — both +stacks are Skia, so AA/font-hinting/taffy-vs-flexbox guarantee sub-pixel and +position deltas; byte-exact is the wrong target): + + - bbox parity : ratio of rendered pixel dims (ours vs baseline). Divergence + here = LAYOUT gap (taffy != Pencil JS flexbox). + - SSIM : windowed structural similarity on luma, after resizing both + to the baseline's dims (isolates PAINT content from size). + - dE76 : mean CIE76 colour difference in LAB (isolates fill/variable + resolution — the "white circle" class). + +Verdict per node: + MISSING : node rendered on one side only (structural) + LAYOUT : bbox dims differ > --bbox-tol (paint may still match once resized) + PAINT : bbox ok but SSIM < --ssim-min or dE76 > --de-max + PASS : within all tolerances + +Usage: + diff_nodes.py [--out report] [--ssim-min 0.90] + [--de-max 5.0] [--bbox-tol 0.03] +""" +import argparse +import json +import sys +from pathlib import Path + +import numpy as np +from PIL import Image +from scipy.ndimage import uniform_filter + + +def load_rgb(path): + return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64) + + +def to_luma(rgb): + return rgb @ np.array([0.299, 0.587, 0.114]) + + +def ssim(a, b, win=7): + """Windowed SSIM on two equal-shape luma arrays (0..255).""" + C1 = (0.01 * 255) ** 2 + C2 = (0.03 * 255) ** 2 + mu_a = uniform_filter(a, win) + mu_b = uniform_filter(b, win) + mu_a2, mu_b2, mu_ab = mu_a * mu_a, mu_b * mu_b, mu_a * mu_b + var_a = uniform_filter(a * a, win) - mu_a2 + var_b = uniform_filter(b * b, win) - mu_b2 + cov = uniform_filter(a * b, win) - mu_ab + num = (2 * mu_ab + C1) * (2 * cov + C2) + den = (mu_a2 + mu_b2 + C1) * (var_a + var_b + C2) + return float(np.clip(num / den, 0, 1).mean()) + + +def srgb_to_lab(rgb): + """rgb in 0..255 -> CIE L*a*b* (D65).""" + c = rgb / 255.0 + lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) + m = np.array([ + [0.4124, 0.3576, 0.1805], + [0.2126, 0.7152, 0.0722], + [0.0193, 0.1192, 0.9505], + ]) + xyz = lin @ m.T + white = np.array([0.95047, 1.0, 1.08883]) + xyz = xyz / white + d = 6 / 29 + f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4 / 29) + L = 116 * f[..., 1] - 16 + a = 500 * (f[..., 0] - f[..., 1]) + bb = 200 * (f[..., 1] - f[..., 2]) + return np.stack([L, a, bb], axis=-1) + + +def mean_de76(a_rgb, b_rgb): + la, lb = srgb_to_lab(a_rgb), srgb_to_lab(b_rgb) + return float(np.sqrt(((la - lb) ** 2).sum(-1)).mean()) + + +def resize_to(arr, hw): + h, w = hw + img = Image.fromarray(arr.astype(np.uint8)).resize((w, h), Image.BILINEAR) + return np.asarray(img, dtype=np.float64) + + +def diff_node(base_path, ours_path, args): + base = load_rgb(base_path) + ours = load_rgb(ours_path) + bh, bw = base.shape[:2] + oh, ow = ours.shape[:2] + dim_ratio_w = ow / bw if bw else 0.0 + dim_ratio_h = oh / bh if bh else 0.0 + bbox_off = max(abs(dim_ratio_w - 1), abs(dim_ratio_h - 1)) + bbox_ok = bbox_off <= args.bbox_tol + + # Normalize ours onto baseline dims to isolate paint from size. + ours_n = resize_to(ours, (bh, bw)) if (oh, ow) != (bh, bw) else ours + # Pad luma to >= window so uniform_filter is meaningful. + s = ssim(to_luma(base), to_luma(ours_n)) if min(bh, bw) >= 3 else 1.0 + de = mean_de76(base, ours_n) + + # Color/fill fidelity INDEPENDENT of position. A heavy 24x24 downsample + # averages out text + sub-pixel element-position drift, leaving only the + # large fills / gradients / backgrounds. Low here ⇒ the colours are right, + # so any SSIM / full-res dE loss is internal position drift = LAYOUT + # (taffy vs Pencil flexbox), NOT a paint bug (fill/variable/shader). High + # here ⇒ a genuine fill divergence = PAINT. This separates the goal's two + # 归因 buckets, which a bbox-only split conflates (a spatially-varying + # gradient at a drifted position reads as huge full-res dE while the fill + # itself is pixel-correct — verified visually on XUa6B's Programs cards). + color_de = mean_de76(resize_to(base, (24, 24)), resize_to(ours_n, (24, 24))) + + if not bbox_ok: + verdict = "LAYOUT" + elif color_de > args.de_max: + verdict = "PAINT" + elif s >= args.ssim_min and de <= args.de_max: + verdict = "PASS" + else: + # Fills match (low downsampled dE); the SSIM / edge-dE loss is + # element-position drift between the two flex engines = LAYOUT. + verdict = "LAYOUT" + return { + "verdict": verdict, + "ssim": round(s, 4), + "de76": round(de, 2), + "color_de": round(color_de, 2), + "bbox_off": round(bbox_off, 4), + "base_dims": [bw, bh], + "ours_dims": [ow, oh], + } + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("baseline") + ap.add_argument("ours") + ap.add_argument("--out", default="report") + ap.add_argument("--ssim-min", type=float, default=0.90) + ap.add_argument("--de-max", type=float, default=5.0) + ap.add_argument("--bbox-tol", type=float, default=0.03) + args = ap.parse_args() + + base_dir, ours_dir = Path(args.baseline), Path(args.ours) + base_ids = {p.stem: p for p in base_dir.glob("*.png")} + ours_ids = {p.stem: p for p in ours_dir.glob("*.png")} + all_ids = sorted(set(base_ids) | set(ours_ids)) + + rows = [] + for nid in all_ids: + if nid not in base_ids or nid not in ours_ids: + rows.append({"id": nid, "verdict": "MISSING", + "side": "ours" if nid in ours_ids else "baseline"}) + continue + r = diff_node(base_ids[nid], ours_ids[nid], args) + r["id"] = nid + rows.append(r) + + counts = {} + for r in rows: + counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1 + total = len(rows) + passed = counts.get("PASS", 0) + summary = { + "total_nodes": total, + "pass": passed, + "pass_rate": round(passed / total, 3) if total else 0.0, + "counts": counts, + "thresholds": {"ssim_min": args.ssim_min, "de_max": args.de_max, + "bbox_tol": args.bbox_tol}, + } + + Path(f"{args.out}.json").write_text( + json.dumps({"summary": summary, "nodes": rows}, indent=2)) + + # Markdown: worst first (LAYOUT/PAINT/MISSING before PASS), then by ssim. + order = {"MISSING": 0, "LAYOUT": 1, "PAINT": 2, "PASS": 3} + rows_sorted = sorted(rows, key=lambda r: (order.get(r["verdict"], 9), + r.get("ssim", 1.0))) + lines = [f"# Render parity: `{base_dir.name}` (Pencil) vs ours\n", + f"- nodes: **{total}** · PASS **{passed}** " + f"({summary['pass_rate']*100:.1f}%) · {counts}\n", + f"- thresholds: SSIM≥{args.ssim_min} dE76≤{args.de_max} " + f"bbox±{args.bbox_tol*100:.0f}%\n", + "\n| node | verdict | SSIM | dE76 | colorDE | bbox_off | base | ours |", + "|---|---|---|---|---|---|---|---|"] + for r in rows_sorted: + if r["verdict"] == "MISSING": + lines.append(f"| `{r['id']}` | MISSING ({r['side']}) | — | — | — | — | — | — |") + else: + bd = "×".join(map(str, r["base_dims"])) + od = "×".join(map(str, r["ours_dims"])) + lines.append(f"| `{r['id']}` | {r['verdict']} | {r['ssim']} | " + f"{r['de76']} | {r.get('color_de', '—')} | {r['bbox_off']} | {bd} | {od} |") + Path(f"{args.out}.md").write_text("\n".join(lines) + "\n") + + print(json.dumps(summary, indent=2)) + print(f"\nwrote {args.out}.json + {args.out}.md") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/render-parity/prep_parity.sh b/scripts/render-parity/prep_parity.sh new file mode 100644 index 000000000..f9a8b676b --- /dev/null +++ b/scripts/render-parity/prep_parity.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# prep_parity.sh [scale] +# +# Deterministic (no-Pencil) half of the render-parity loop: +# 1. pen2op converts -> .op (preserves node ids) +# 2. openpencil-desktop --render-shots renders our side, one PNG per top node +# 3. prints the top-level node-id JSON array to paste into the Pencil MCP +# export_nodes call that captures the baseline (Pencil's own render). +# +# After running this, capture the baseline via MCP: +# export_nodes(filePath=, outputDir=, +# nodeIds=, scale=, format=png) +# then diff: +# diff_nodes.py --out +set -euo pipefail + +PEN="$1"; NAME="$2"; SCALE="${3:-2}" +ROOT="${RPARITY_ROOT:?set RPARITY_ROOT to a working dir}" +BIN="${OPENPENCIL_BIN:-/Users/fini/workspace/openpencil/target/debug}" + +OP="$ROOT/$NAME.op" +OURS="$ROOT/$NAME/ours" +BASE="$ROOT/$NAME/baseline" +mkdir -p "$OURS" "$BASE" + +"$BIN/pen2op" "$PEN" "$OP" >&2 +"$BIN/openpencil-desktop" --render-shots "$OP" "$OURS" "$SCALE" >&2 + +ids=$(ls "$OURS"/*.png | xargs -n1 basename | sed 's/\.png$//') +json="[$(echo "$ids" | sed 's/.*/"&"/' | paste -sd, -)]" + +echo "NAME=$NAME" +echo "SOURCE_PEN=$PEN" +echo "OURS_DIR=$OURS" +echo "BASELINE_DIR=$BASE" +echo "SCALE=$SCALE" +echo "NODE_COUNT=$(echo "$ids" | grep -c .)" +echo "NODE_IDS_JSON=$json"