| """Compute per-image rendering diffs between two output directories. |
| |
| Pairs PNGs by relative path under each root (e.g. .../initializerply/images/<scene>/color_target/*.png) |
| and reports max|diff|, mean|diff|, PSNR. Useful for comparing the gsplat and inria decoders |
| on the same init. |
| |
| Usage: |
| python -m optgs.scripts.diff_renders <root_a> <root_b> [--subdir initializerply/images] \ |
| [--save-diff <out_dir>] [--top-k 10] |
| """ |
| import argparse |
| import json |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
| from PIL import Image |
|
|
|
|
| def collect_pngs(root: Path, subdir: str) -> dict[str, Path]: |
| base = root / subdir if subdir else root |
| if not base.exists(): |
| sys.exit(f"Missing path: {base}") |
| return {str(p.relative_to(base)): p for p in base.rglob("*.png")} |
|
|
|
|
| def diff_pair(a_path: Path, b_path: Path) -> dict: |
| a = np.asarray(Image.open(a_path).convert("RGB"), dtype=np.float32) / 255.0 |
| b = np.asarray(Image.open(b_path).convert("RGB"), dtype=np.float32) / 255.0 |
| if a.shape != b.shape: |
| return {"shape_a": a.shape, "shape_b": b.shape, "skipped": True} |
| d = np.abs(a - b) |
| mse = float((d ** 2).mean()) |
| psnr = float(20 * np.log10(1.0) - 10 * np.log10(mse + 1e-12)) |
| return { |
| "max_abs": float(d.max()), |
| "mean_abs": float(d.mean()), |
| "mse": mse, |
| "psnr": psnr, |
| "shape": list(a.shape), |
| } |
|
|
|
|
| def save_diff_image(a_path: Path, b_path: Path, out_path: Path, scale: float = 5.0) -> None: |
| a = np.asarray(Image.open(a_path).convert("RGB"), dtype=np.float32) / 255.0 |
| b = np.asarray(Image.open(b_path).convert("RGB"), dtype=np.float32) / 255.0 |
| if a.shape != b.shape: |
| return |
| d = np.clip(np.abs(a - b) * scale, 0, 1) |
| out_path.parent.mkdir(parents=True, exist_ok=True) |
| Image.fromarray((d * 255).astype(np.uint8)).save(out_path) |
|
|
|
|
| def main() -> None: |
| p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| p.add_argument("root_a", type=Path) |
| p.add_argument("root_b", type=Path) |
| p.add_argument("--subdir", default="", |
| help="Restrict comparison to this subpath under each root (e.g. 'initializerply/images').") |
| p.add_argument("--save-diff", type=Path, default=None, |
| help="Directory to save scaled |a-b| images, mirroring the relative path.") |
| p.add_argument("--diff-scale", type=float, default=5.0) |
| p.add_argument("--top-k", type=int, default=10, help="Show K worst pairs by max|diff|.") |
| p.add_argument("--json", type=Path, default=None, help="Optional path to dump per-pair stats as JSON.") |
| p.add_argument("--pair", choices=["name", "sorted"], default="name", |
| help="'name': pair by matching relative path. 'sorted': pair by index after sorting " |
| "each root's PNGs (use when filename schemes differ but order corresponds).") |
| args = p.parse_args() |
|
|
| pngs_a = collect_pngs(args.root_a, args.subdir) |
| pngs_b = collect_pngs(args.root_b, args.subdir) |
|
|
| print(f"root_a: {args.root_a / args.subdir if args.subdir else args.root_a}") |
| print(f"root_b: {args.root_b / args.subdir if args.subdir else args.root_b}") |
|
|
| if args.pair == "name": |
| common = sorted(set(pngs_a) & set(pngs_b)) |
| only_a = sorted(set(pngs_a) - set(pngs_b)) |
| only_b = sorted(set(pngs_b) - set(pngs_a)) |
| print(f"pair=name; common: {len(common)}; only_a: {len(only_a)}; only_b: {len(only_b)}") |
| if not common: |
| sys.exit("No common PNGs to diff. Try --pair sorted if filenames differ but order matches.") |
| pairs = [(rel, pngs_a[rel], pngs_b[rel]) for rel in common] |
| else: |
| sa = sorted(pngs_a.items()) |
| sb = sorted(pngs_b.items()) |
| if len(sa) != len(sb): |
| sys.exit(f"pair=sorted: counts differ (root_a={len(sa)}, root_b={len(sb)}); can't pair by index.") |
| print(f"pair=sorted; {len(sa)} pairs") |
| only_a = only_b = [] |
| pairs = [(f"{ra}|{rb}", pa, pb) for (ra, pa), (rb, pb) in zip(sa, sb)] |
|
|
| results = [] |
| skipped = [] |
| for rel, a_path, b_path in pairs: |
| stats = diff_pair(a_path, b_path) |
| if stats.get("skipped"): |
| skipped.append((rel, stats)) |
| continue |
| results.append((rel, stats)) |
| if args.save_diff is not None: |
| save_diff_image(a_path, b_path, args.save_diff / rel.replace("|", "_VS_"), scale=args.diff_scale) |
|
|
| if skipped: |
| print(f"\nShape-mismatch pairs ({len(skipped)}):") |
| for rel, s in skipped[:10]: |
| print(f" {rel}: {s['shape_a']} vs {s['shape_b']}") |
|
|
| if not results: |
| sys.exit("All pairs had mismatched shapes.") |
|
|
| max_abs = np.array([s["max_abs"] for _, s in results]) |
| mean_abs = np.array([s["mean_abs"] for _, s in results]) |
| psnr = np.array([s["psnr"] for _, s in results]) |
|
|
| print(f"\nPer-pair stats ({len(results)} pairs):") |
| print(f" max|diff| — min: {max_abs.min():.4e} median: {np.median(max_abs):.4e} max: {max_abs.max():.4e}") |
| print(f" mean|diff| — min: {mean_abs.min():.4e} median: {np.median(mean_abs):.4e} max: {mean_abs.max():.4e}") |
| print(f" PSNR(dB) — min: {psnr.min():.2f} median: {np.median(psnr):.2f} max: {psnr.max():.2f}") |
|
|
| results.sort(key=lambda r: -r[1]["max_abs"]) |
| print(f"\nWorst {min(args.top_k, len(results))} pairs by max|diff|:") |
| for rel, s in results[: args.top_k]: |
| print(f" max={s['max_abs']:.4e} mean={s['mean_abs']:.4e} psnr={s['psnr']:.2f}dB {rel}") |
|
|
| if args.json is not None: |
| args.json.parent.mkdir(parents=True, exist_ok=True) |
| with open(args.json, "w") as f: |
| json.dump( |
| { |
| "root_a": str(args.root_a), |
| "root_b": str(args.root_b), |
| "subdir": args.subdir, |
| "common_count": len(common), |
| "only_a": only_a, |
| "only_b": only_b, |
| "pairs": [{"rel": r, **s} for r, s in results], |
| "skipped": [{"rel": r, **s} for r, s in skipped], |
| }, |
| f, |
| indent=2, |
| ) |
| print(f"\nWrote per-pair stats to {args.json}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|