Files
resolved/scripts/gallery-delta.py
T
2026-10-18 15:21:05 -04:00

305 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Renders the example scenes at two revisions and compares them, for
scripts/serve-gallery.py's Delta tab: what a change did to the images.
python3 scripts/gallery-delta.py [--base REV] [--head REV] [--noise] [--scenes a,b]
With no revisions: with uncommitted changes, HEAD against the working tree; with none,
HEAD~1 against HEAD. A revision is a commit (anything git rev-parse takes) or
`worktree`, the working tree as it is.
Each revision renders every example scene in its own examples/scenes/ at the gallery's
tiers (lighting L0, L1 and L2, camera C1), the pretty preview for looking at and the raw DN
for comparing. Renders are noiseless (`resolved render --noiseless`), so an unchanged
scene comes out identical and a changed one differs only where its light does; --noise
keeps the detector's noise, which then also differs wherever the light does. A commit
is built in its own git worktree under target/delta/, with data/ and the fetched models
linked in, and its renders are kept there by commit, so comparing the next commit
reuses them. The working tree is built and rendered fresh each time.
The comparison goes to gallery/delta/: before, after and difference images, and
summary.json with the change in DN per image. The difference image is the after image
dimmed to gray, with every pixel that changed colored by how much on a log scale:
orange where it got brighter, blue where it got darker.
Needs numpy, Pillow and ImageMagick (`magick`, to read 16-bit PNGs, which Pillow reads
as 8-bit). The first build of a commit takes a few minutes; later ones are incremental.
After deleting target/, `git worktree prune` forgets the worktrees.
"""
import argparse
import concurrent.futures
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
DELTA = ROOT / "target" / "delta"
OUT = ROOT / "gallery" / "delta"
TIERS = ["L0", "L1", "L2"]
CAMERA = "C1"
# Renders at once. Each loads its own copy of the Earth and Moon maps; four at once ran
# the RTX 3060 (12 GB) out of memory, and with a sharp Earth map (4.9 GB) two do.
WORKERS = 1 if any((ROOT / "data" / "earth").glob("sharp-*.tiles")) else 2
BRIGHTER = np.array([255, 150, 40], dtype=float)
DARKER = np.array([60, 140, 255], dtype=float)
def git(*args, cwd=ROOT):
return subprocess.run(
["git", *args], cwd=cwd, check=True, capture_output=True, text=True
).stdout.strip()
def dirty():
return bool(git("status", "--porcelain"))
class Side:
"""One revision: where its sources are, how to build it, where its renders go."""
def __init__(self, rev, slot):
if rev == "worktree":
self.sha = None
self.label = "working tree"
self.root = ROOT
self.target = ROOT / "target"
self.renders = DELTA / "renders" / "worktree"
else:
self.sha = git("rev-parse", "--verify", f"{rev}^{{commit}}")
self.label = git("log", "-1", "--format=%h %s", self.sha)
self.root = DELTA / slot
self.target = DELTA / f"{slot}-target"
self.renders = DELTA / "renders" / self.sha
def prepare(self):
"""Checks the revision out into its worktree, with the untracked data linked."""
if self.sha is None:
return
git("worktree", "prune")
if (self.root / ".git").exists():
git("checkout", "--detach", "--force", self.sha, cwd=self.root)
else:
DELTA.mkdir(parents=True, exist_ok=True)
git("worktree", "add", "--detach", "--force", str(self.root), self.sha)
links = {
self.root / "data": ROOT / "data",
self.root / "examples" / "models" / "nasa": ROOT / "examples" / "models" / "nasa",
# The library's astrokit path dependency, ../../../astrokit from its crate.
DELTA / "astrokit": (ROOT / ".." / "astrokit").resolve(),
}
for link, target in links.items():
if target.exists() and not link.exists():
link.symlink_to(target)
def build(self):
env = dict(os.environ, CARGO_TARGET_DIR=str(self.target))
print(f"building {self.label}", flush=True)
subprocess.run(
["cargo", "build", "--quiet", "--release", "-p", "resolved-cli"],
cwd=self.root,
env=env,
check=True,
)
self.binary = self.target / "release" / "resolved"
usage = subprocess.run(
[self.binary, "render", "--help"], capture_output=True, text=True
).stdout
self.has_noiseless = "--noiseless" in usage
def scenes(self):
return {p.stem: p for p in sorted((self.root / "examples" / "scenes").glob("*.toml"))}
def render_all(self, names, noise, fresh):
"""Renders each scene at each tier into its own directory, skipping those
already rendered at this commit (failures are tried again). Returns
{(scene, tier): directory or error}."""
mode = "noise" if noise else "noiseless"
base = self.renders / mode
if self.sha is None or fresh:
shutil.rmtree(base, ignore_errors=True)
scenes = self.scenes()
jobs = [(n, t) for n in names if n in scenes for t in TIERS]
def one(job):
name, tier = job
out = base / f"{name}-{tier}"
if (out / "dn.png").is_file():
return job, out
out.mkdir(parents=True, exist_ok=True)
args = [
self.binary, "render", "--scene", str(scenes[name].relative_to(self.root)),
"--lighting", tier, "--camera", CAMERA, "--pretty",
"--preview", str(out / "pretty.png"), "--out", str(out / "dn.png"),
]
if not noise:
args.append("--noiseless")
result = subprocess.run(args, cwd=self.root, capture_output=True, text=True)
if result.returncode != 0:
return job, result.stderr.strip().removeprefix("error: ")
return job, out
print(f"rendering {self.label}: {len(jobs)} images", flush=True)
with concurrent.futures.ThreadPoolExecutor(WORKERS) as pool:
return dict(pool.map(one, jobs))
def read_dn(path):
"""A 16-bit PNG as DN: its values shifted down past the zero padding below them."""
image = Image.open(path)
width, height = image.size
channels = len(image.getbands())
kind = "rgb" if channels == 3 else "gray"
raw = subprocess.run(
["magick", str(path), "-depth", "16", "-endian", "LSB", f"{kind}:-"],
check=True,
capture_output=True,
).stdout
values = np.frombuffer(raw, "<u2").reshape(height, width, -1)
return values
def padding(values):
"""The number of zero bits below every value: 4 for 12-bit DN in 16 bits."""
bits = int(np.bitwise_or.reduce(values, axis=None))
return (bits & -bits).bit_length() - 1 if bits else 0
def difference_image(after_pretty, delta):
"""The after image dimmed to gray, with changed pixels colored on a log scale."""
gray = np.asarray(Image.open(after_pretty).convert("L"), dtype=float)[..., None]
picture = np.repeat(gray * 0.3, 3, axis=2)
size = np.abs(delta).max(axis=2).astype(float)
largest = size.max()
if largest > 0:
changed = size > 0
strength = 0.35 + 0.65 * np.log1p(size) / np.log1p(largest)
color = np.where((delta.sum(axis=2) >= 0)[..., None], BRIGHTER, DARKER)
mix = (strength * changed)[..., None]
picture = picture * (1 - mix) + color * mix
return Image.fromarray(np.clip(picture, 0, 255).astype(np.uint8))
def compare(before, after, scene_changed, name, tier):
item = {"scene": name, "tier": tier, "scene_changed": scene_changed}
stem = f"{name}-{tier}"
if isinstance(before, Path):
shutil.copy(before / "pretty.png", OUT / f"{stem}-before.png")
item["before"] = f"{stem}-before.png"
if isinstance(after, Path):
shutil.copy(after / "pretty.png", OUT / f"{stem}-after.png")
item["after"] = f"{stem}-after.png"
if not isinstance(before, Path) or not isinstance(after, Path):
# Missing, or failed only before (a tier the earlier revision lacks): new.
if isinstance(after, Path):
item["status"] = "new"
else:
item["status"] = "removed" if after is None else "failed"
for side, value in (("before", before), ("after", after)):
if isinstance(value, str):
item[f"{side}_error"] = value
return item
a, b = read_dn(before / "dn.png"), read_dn(after / "dn.png")
if a.shape != b.shape:
item.update(status="resized", shape=[list(a.shape), list(b.shape)])
return item
shift = min(padding(a), padding(b))
delta = (b.astype(np.int32) >> shift) - (a.astype(np.int32) >> shift)
size = np.abs(delta)
changed = (size.max(axis=2) > 0).mean()
item.update(
status="changed" if changed > 0 else "same",
pixels=int((size.max(axis=2) > 0).sum()),
max_dn=int(size.max()),
mean_dn=float(size.mean()),
changed=float(changed),
brighter=float((delta.sum(axis=2) > 0).mean()),
darker=float((delta.sum(axis=2) < 0).mean()),
)
if changed > 0:
difference_image(after / "pretty.png", delta).save(OUT / f"{stem}-diff.png")
item["diff"] = f"{stem}-diff.png"
return item
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--base", help="the revision before (default: see above)")
parser.add_argument("--head", help="the revision after, or worktree")
parser.add_argument("--noise", action="store_true", help="keep the detector's noise")
parser.add_argument("--scenes", help="comma-separated scene names (default: all)")
parser.add_argument("--fresh", action="store_true", help="render commits again")
args = parser.parse_args()
if args.base is None and args.head is None:
args.base, args.head = ("HEAD", "worktree") if dirty() else ("HEAD~1", "HEAD")
base = Side(args.base or "HEAD", "base")
head = Side(args.head or "worktree", "head")
print(f"before: {base.label}\nafter: {head.label}", flush=True)
started = time.time()
for side in (base, head):
side.prepare()
side.build()
noise = args.noise
if not noise and not (base.has_noiseless and head.has_noiseless):
print("one revision's CLI has no --noiseless: rendering both with noise", flush=True)
noise = True
before_scenes, after_scenes = base.scenes(), head.scenes()
names = sorted(before_scenes.keys() | after_scenes.keys())
if args.scenes:
wanted = set(args.scenes.split(","))
names = [n for n in names if n in wanted]
before = base.render_all(names, noise, args.fresh)
after = head.render_all(names, noise, args.fresh)
shutil.rmtree(OUT, ignore_errors=True)
OUT.mkdir(parents=True)
items = []
for name in names:
scene_changed = (
name in before_scenes
and name in after_scenes
and before_scenes[name].read_bytes() != after_scenes[name].read_bytes()
)
for tier in TIERS:
items.append(
compare(before.get((name, tier)), after.get((name, tier)), scene_changed,
name, tier)
)
summary = {
"base": base.label,
"head": head.label,
"noise": noise,
"tiers": [f"{t}/{CAMERA}" for t in TIERS],
"made": time.strftime("%Y-%m-%d %H:%M"),
"items": items,
}
(OUT / "summary.json").write_text(json.dumps(summary, indent=1))
counts = {}
for item in items:
counts[item["status"]] = counts.get(item["status"], 0) + 1
print(
f"{len(items)} images in {time.time() - started:.0f} s: "
+ ", ".join(f"{n} {s}" for s, n in sorted(counts.items()))
)
for item in items:
if item["status"] == "changed":
print(
f" {item['scene']} {item['tier']}: {item['pixels']} pixels "
f"({100 * item['changed']:.3g} %), "
f"up to {item['max_dn']} DN"
)
print("view with scripts/serve-gallery.py (the Delta tab)")
if __name__ == "__main__":
sys.exit(main())