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, "
Run '
+ "python3 scripts/gallery-delta.py, then reload.
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.
{stats}
{count} renders of {len(names)} example scenes, last rendered {when}. Click an image to see it full size.