Added the delta-tooling
This commit is contained in:
1 parent
cc70cea7f6
commit
a3f62301f4
5 files changed
+470
-10
No files matched your search
+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