From a3f62301f4b3cca9b0b125b45b8cc99979c19cf5 Mon Sep 17 00:00:00 2001
From: Connor Johnstone
Date: Sat, 17 Oct 2026 20:14:08 -0400
Subject: [PATCH] Added the delta-tooling
---
crates/resolved-cli/src/main.rs | 9 +-
crates/resolved-cli/tests/cli.rs | 35 ++++
readme.md | 14 +-
scripts/gallery-delta.py | 300 +++++++++++++++++++++++++++++++
scripts/serve-gallery.py | 122 ++++++++++++-
5 files changed, 470 insertions(+), 10 deletions(-)
create mode 100755 scripts/gallery-delta.py
diff --git a/crates/resolved-cli/src/main.rs b/crates/resolved-cli/src/main.rs
index a612d20..d154769 100644
--- a/crates/resolved-cli/src/main.rs
+++ b/crates/resolved-cli/src/main.rs
@@ -75,6 +75,10 @@ struct RenderArgs {
/// give the same image.
#[arg(long, default_value_t = 0)]
frame: u64,
+ /// From camera C1: leave out the detector's noise, dark current and fixed pattern
+ /// (saturation and blooming stay), so renders differ only where their light does.
+ #[arg(long)]
+ noiseless: bool,
/// From camera C2: how far in pixels the PSF must reach from a point (default 16).
/// The FFT grid grows to hold it, and the PSF then reaches as far as the grid
/// allows: 64 px at 1920 x 1080 and 2 x 2 supersampling, up to 1088 px at 1 x 1.
@@ -169,7 +173,10 @@ fn render(args: &RenderArgs) -> Result<(), String> {
let path = args.scene.as_path();
let file = SceneFile::read(path)?;
let dir = path.parent().unwrap_or(Path::new("."));
- let camera = file.camera(dir)?;
+ let mut camera = file.camera(dir)?;
+ if args.noiseless {
+ camera.detector = camera.detector.noiseless();
+ }
// What the tiers need, gathered before the renderer exists so it can check them.
let start = Instant::now();
diff --git a/crates/resolved-cli/tests/cli.rs b/crates/resolved-cli/tests/cli.rs
index 703cd28..a8c2921 100644
--- a/crates/resolved-cli/tests/cli.rs
+++ b/crates/resolved-cli/tests/cli.rs
@@ -120,6 +120,41 @@ fn example_scene_on_gpu_and_cpu() {
);
}
+#[test]
+fn noiseless_renders_do_not_depend_on_the_seed() {
+ let render = |seed: &str, noiseless: bool| {
+ let out = out_dir().join(format!("noiseless-{seed}-{noiseless}.png"));
+ let mut args = vec![
+ "render",
+ "--scene",
+ "examples/scenes/boxwing.toml",
+ "--adapter",
+ "gpu",
+ "--camera",
+ "C1",
+ "--seed",
+ seed,
+ "--out",
+ out.to_str().unwrap(),
+ ];
+ if noiseless {
+ args.push("--noiseless");
+ }
+ let result = resolved(&args);
+ assert!(
+ result.status.success(),
+ "{}",
+ String::from_utf8_lossy(&result.stderr)
+ );
+ image::open(&out).unwrap().into_rgb16()
+ };
+ assert_ne!(render("1", false), render("2", false));
+ let quiet = render("1", true);
+ assert_eq!(quiet, render("2", true));
+ // Empty space reads exactly the bias, with no dark current or read noise.
+ assert_eq!(quiet.get_pixel(0, 0).0, [64 << 4; 3]);
+}
+
#[test]
fn pretty_previews_lift_the_darks_and_leave_space_black() {
let preview = |pretty: bool| {
diff --git a/readme.md b/readme.md
index 48cdb53..4d3fe31 100644
--- a/readme.md
+++ b/readme.md
@@ -150,6 +150,16 @@ camera C1, into `gallery/` with `--pretty`, skipping any whose model or data
is missing, and `scripts/serve-gallery.py` serves the result as a web page on
port 8000 of every interface, each scene captioned from its file.
+`scripts/gallery-delta.py` shows what a change did to the images: it renders
+the example scenes at two revisions, noiseless, and the gallery's Delta tab
+shows each changed image before, after, and with its changed pixels colored
+(orange brighter, blue darker), with the change in DN. With no arguments it
+compares HEAD with uncommitted changes, or HEAD~1 with HEAD when there are
+none; `--base` and `--head` pick others. Each commit is built in a git worktree
+under `target/delta/` and its renders are kept, so comparing the next commit
+reuses them. About 40 s on the RTX 3060 once built; it needs numpy, Pillow and
+ImageMagick.
+
`crates/resolved-showcase` makes a 30-second movie at lighting L1 and camera
C1: an inspector holding station behind a Landsat 8 model 705 km up, which
astrokit propagates with J2. Time runs fast over the daylit Earth, slows as
@@ -189,7 +199,9 @@ cargo run --release -p resolved-showcase -- --serve 0.0.0.0:8010
2 x 2). A tier the scene or the data cannot support stops with an error naming
what is missing.
- `--seed N` and `--frame N` pick the detector noise at camera C1 (default 0
- and 0): the same seed and frame give the same image.
+ and 0): the same seed and frame give the same image. `--noiseless` leaves out
+ the noise, dark current and fixed pattern, so two renders differ only where
+ their light does.
- `--data DIR` says where the tiers' data is (`earth/bmng-2004-MM.jpg`,
`moon/wac-hapke-643.png` and `stars/stars.bin`).
Without it the CLI tries `RESOLVED_DATA`, then `./data`, then the
diff --git a/scripts/gallery-delta.py b/scripts/gallery-delta.py
new file mode 100755
index 0000000..1761e48
--- /dev/null
+++ b/scripts/gallery-delta.py
@@ -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, " 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())
diff --git a/scripts/serve-gallery.py b/scripts/serve-gallery.py
index 31bf746..45ee5ad 100755
--- a/scripts/serve-gallery.py
+++ b/scripts/serve-gallery.py
@@ -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.
"""
+def delta_section():
+ summary = DELTA / "summary.json"
+ if not summary.is_file():
+ return (
+ '
No comparison yet
Run '
+ "python3 scripts/gallery-delta.py, then reload.
"
+ )
+ 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'
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.
"
+ )
+
+ 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'{caption}'
+ )
+
+ changed, same, other = [], [], []
+ for item in data["items"]:
+ name = f'{item["scene"]} {item["tier"]}'
+ note = ' (scene file changed)' if item["scene_changed"] else ""
+ if item["status"] == "same":
+ same.append(f"
{html.escape(name)}{note}
")
+ continue
+ if item["status"] == "changed":
+ stats = (
+ f'{item["pixels"]:,} pixels changed ({100 * item["changed"]:.3g} %: '
+ f'{100 * item["brighter"]:.3g} % brighter, {100 * item["darker"]:.3g} % '
+ f'darker), up to {item["max_dn"]} DN, '
+ 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'{item["status"]} ' + html.escape("; ".join(errors))
+ figures = []
+ if "before" in item:
+ figures.append(figure(item["before"], f"{name}, before", "before",
+ item.get("after"), f"{name}, after"))
+ if "after" in item:
+ figures.append(figure(item["after"], f"{name}, after", "after",
+ item.get("before"), f"{name}, before"))
+ if "diff" in item:
+ figures.append(figure(item["diff"], f"{name}, difference", "difference"))
+ card = (
+ f"
{html.escape(name)}{note}
"
+ f'
{stats}
{"".join(figures)}
'
+ )
+ (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"
Unchanged ({len(same)})
"
+ f'
{"".join(same)}
'
+ )
+ 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):
resolved gallery
{count} renders of {len(names)} example scenes, last rendered {when}.
Click an image to see it full size.