Added the delta-tooling
This commit is contained in:
1 parent
cc70cea7f6
commit
a3f62301f4
5 files changed
+470
-10
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+300
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user