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

+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.