Added the delta-tooling

This commit is contained in:
connor committed 2026-10-17 20:14:08 -04:00
1 parent cc70cea7f6
commit a3f62301f4
5 files changed
+470 -10

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
#!/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 and L1, 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"]
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.
WORKERS = 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):
item["status"] = "new" if before is None else "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())
+114 -8
View File
@@ -12,6 +12,11 @@ Movie: gallery/movie.mp4 (from make-gallery.sh or resolved-movie --out) to play
download, and the live stream from `resolved-movie --serve 0.0.0.0:8010` on this
machine, which the page connects to only while the tab is open.
Delta: the comparison scripts/gallery-delta.py writes to gallery/delta/, the example
scenes before and after a change. Scenes that changed come first, each with its before,
after and difference images; in the full-size view, Space flips between before and
after.
The page is built on each request, so rerunning make-gallery.sh and reloading shows
the new images. Only the PNG files and movie.mp4 in gallery/ are served.
@@ -20,6 +25,7 @@ Needs only the Python standard library.
import argparse
import html
import json
import socket
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -28,6 +34,7 @@ from urllib.parse import unquote
ROOT = Path(__file__).resolve().parent.parent
GALLERY = ROOT / "gallery"
DELTA = GALLERY / "delta"
SCENES = ROOT / "examples" / "scenes"
TIERS = ["L0", "L1"]
# The camera tier make-gallery.sh renders with.
@@ -95,42 +102,63 @@ nav a.on { color: var(--bg); background: var(--accent); border-color: var(--acce
}
.movie .live { object-fit: contain; }
.movie a.download { color: var(--accent); }
.trio { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.stats { margin: 0 0 0.9rem; color: var(--muted); font-size: 0.9rem; }
.stats b { color: var(--text); font-weight: 600; }
.flag { color: var(--accent); }
.same { color: var(--muted); }
.same li { margin: 0.15rem 0; }
.movie code { color: var(--text); }
"""
SCRIPT = """
const live = document.getElementById('live');
const TABS = ['stills', 'movie', 'delta'];
function tab() {
const movie = location.hash === '#movie';
const name = TABS.includes(location.hash.slice(1)) ? location.hash.slice(1) : 'stills';
// The panels' ids differ from the links' anchors, so a link switches the tab
// instead of scrolling the page to it.
document.getElementById('stills-tab').hidden = movie;
document.getElementById('movie-tab').hidden = !movie;
TABS.forEach((t) => { document.getElementById(t + '-tab').hidden = t !== name; });
document.querySelectorAll('nav a').forEach(
(a) => a.classList.toggle('on', (a.hash === '#movie') === movie));
(a) => a.classList.toggle('on', a.hash === '#' + name));
// The live stream renders only while someone watches: connect only on this tab.
if (live) live.src = movie ? live.dataset.src : '';
if (live) live.src = name === 'movie' ? live.dataset.src : '';
}
window.addEventListener('hashchange', tab);
tab();
const images = [...document.querySelectorAll('figure img')];
const view = document.getElementById('view');
const big = view.querySelector('img');
const label = view.querySelector('div');
let images = [];
let current = -1;
let flipped = false;
function show(i) {
current = (i + images.length) % images.length;
flipped = false;
big.src = images[current].src;
label.textContent = images[current].alt;
view.classList.add('open');
}
images.forEach((img, i) => img.addEventListener('click', () => show(i)));
// Delta images carry their pair: Space shows the other one of before and after.
function flip() {
const img = images[current];
if (!img.dataset.other) return;
flipped = !flipped;
big.src = flipped ? img.dataset.other : img.src;
label.textContent = flipped ? img.dataset.otherAlt : img.alt;
}
document.querySelectorAll('figure img').forEach((img) => img.addEventListener('click', () => {
// Step through the images of the tab the clicked one is on.
images = [...img.closest('main').querySelectorAll('figure img')];
show(images.indexOf(img));
}));
view.addEventListener('click', () => view.classList.remove('open'));
document.addEventListener('keydown', (e) => {
if (!view.classList.contains('open')) return;
if (e.key === 'Escape') view.classList.remove('open');
if (e.key === 'ArrowRight') show(current + 1);
if (e.key === 'ArrowLeft') show(current - 1);
if (e.key === ' ') { e.preventDefault(); flip(); }
});
"""
@@ -188,6 +216,76 @@ it renders only while this tab is open.</p>
</section>"""
def delta_section():
summary = DELTA / "summary.json"
if not summary.is_file():
return (
'<section><h2>No comparison yet</h2><p class="caption">Run '
"<code>python3 scripts/gallery-delta.py</code>, then reload.</p></section>"
)
data = json.loads(summary.read_text())
stamp = int(summary.stat().st_mtime)
noise = "with the detector's noise" if data["noise"] else "noiseless"
head = (
f'<section><h2>{html.escape(data["base"])} → {html.escape(data["head"])}</h2>'
f'<p class="caption">The example scenes at {", ".join(data["tiers"])}, {noise}, '
f'compared {html.escape(data["made"])}. In a difference image the after image is '
"dimmed to gray and every changed pixel is colored, on a log scale of how much: "
"orange brighter, blue darker. Click an image for full size; Space then flips "
"between before and after.</p></section>"
)
def figure(file, alt, caption, other=None, other_alt=None):
pair = ""
if other:
pair = (f' data-other="/delta/{other}?{stamp}" '
f'data-other-alt="{html.escape(other_alt)}"')
return (
f'<figure><img src="/delta/{file}?{stamp}" alt="{html.escape(alt)}"{pair} '
f'loading="lazy"><figcaption>{caption}</figcaption></figure>'
)
changed, same, other = [], [], []
for item in data["items"]:
name = f'{item["scene"]} {item["tier"]}'
note = ' <span class="flag">(scene file changed)</span>' if item["scene_changed"] else ""
if item["status"] == "same":
same.append(f"<li>{html.escape(name)}{note}</li>")
continue
if item["status"] == "changed":
stats = (
f'<b>{item["pixels"]:,} pixels</b> changed ({100 * item["changed"]:.3g} %: '
f'{100 * item["brighter"]:.3g} % brighter, {100 * item["darker"]:.3g} % '
f'darker), up to <b>{item["max_dn"]} DN</b>, '
f'mean {item["mean_dn"]:.3g} DN over the image'
)
else:
errors = [item.get(k) for k in ("before_error", "after_error") if item.get(k)]
stats = f'<b>{item["status"]}</b> ' + html.escape("; ".join(errors))
figures = []
if "before" in item:
figures.append(figure(item["before"], f"{name}, before", "<b>before</b>",
item.get("after"), f"{name}, after"))
if "after" in item:
figures.append(figure(item["after"], f"{name}, after", "<b>after</b>",
item.get("before"), f"{name}, before"))
if "diff" in item:
figures.append(figure(item["diff"], f"{name}, difference", "<b>difference</b>"))
card = (
f"<section><h2>{html.escape(name)}{note}</h2>"
f'<p class="stats">{stats}</p><div class="trio">{"".join(figures)}</div></section>'
)
(changed if item["status"] == "changed" else other).append((item.get("changed", 0), card))
changed.sort(key=lambda c: -c[0])
cards = [c for _, c in changed] + [c for _, c in other]
if same:
cards.append(
f"<section><h2>Unchanged ({len(same)})</h2>"
f'<ul class="same">{"".join(same)}</ul></section>'
)
return head + "".join(cards)
def page(host, live_port):
names = sorted({p.stem.rsplit("-", 1)[0] for p in GALLERY.glob("*.png")})
cards = []
@@ -236,9 +334,10 @@ def page(host, live_port):
<body><header><h1><span>resolved</span> gallery</h1>
<p>{count} renders of {len(names)} example scenes, last rendered {when}.
Click an image to see it full size.</p></header>
<nav><a href="#stills">Stills</a><a href="#movie">Movie</a></nav>
<nav><a href="#stills">Stills</a><a href="#movie">Movie</a><a href="#delta">Delta</a></nav>
<main id="stills-tab">{"".join(cards)}</main>
<main id="movie-tab" hidden>{movie_section(host, live_port)}</main>
<main id="delta-tab" hidden>{delta_section()}</main>
<div id="view"><img alt=""><div></div></div>
<script>{SCRIPT}</script></body></html>"""
@@ -252,6 +351,13 @@ class Handler(BaseHTTPRequestHandler):
self.send(200, "text/html; charset=utf-8", body)
elif path == "/movie.mp4" and (GALLERY / "movie.mp4").is_file():
self.send_movie(GALLERY / "movie.mp4")
elif path.startswith("/delta/"):
name = path[len("/delta/"):]
image = DELTA / name
if "/" in name or not name.endswith(".png") or not image.is_file():
self.send(404, "text/plain", b"not found")
else:
self.send(200, "image/png", image.read_bytes())
elif path.startswith("/img/"):
name = path[len("/img/"):]
# Only a PNG directly in gallery/, by its exact name.