433 lines
18 KiB
Python
Executable File
433 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Serves gallery/ (from scripts/make-gallery.sh) as a web page on every interface, for
|
|
looking at the example renders from another machine on the network.
|
|
|
|
python3 scripts/serve-gallery.py [--port 8000] [--live-port 8010]
|
|
|
|
Stills: each example scene gets a card with its L0, L1 and L2 renders side by side,
|
|
all with camera C1, captioned from the comment at the top of its file in examples/scenes/.
|
|
Click an image to see it full size; arrow keys step through them and Escape closes.
|
|
|
|
Movie: gallery/movie.mp4 (from make-gallery.sh or resolved-movie --out) to play or
|
|
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.
|
|
|
|
Needs only the Python standard library.
|
|
"""
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import socket
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import unquote
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
GALLERY = ROOT / "gallery"
|
|
DELTA = GALLERY / "delta"
|
|
SCENES = ROOT / "examples" / "scenes"
|
|
TIERS = ["L0", "L1", "L2"]
|
|
# The camera tier make-gallery.sh renders with.
|
|
CAMERA = "C1"
|
|
|
|
STYLE = """
|
|
:root {
|
|
--bg: #0b0d12; --card: #141821; --line: #232938; --text: #e6e8ee;
|
|
--muted: #9aa3b5; --accent: #e8b04a;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; background: var(--bg); color: var(--text);
|
|
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
}
|
|
header { padding: 2.5rem 1.5rem 1rem; max-width: 1400px; margin: 0 auto; }
|
|
h1 { margin: 0; font-size: 1.9rem; font-weight: 650; letter-spacing: -0.01em; }
|
|
h1 span { color: var(--accent); }
|
|
header p { margin: 0.4rem 0 0; color: var(--muted); }
|
|
main {
|
|
max-width: 1400px; margin: 0 auto; padding: 1rem 1.5rem 3rem;
|
|
display: grid; gap: 1.5rem;
|
|
}
|
|
section {
|
|
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
|
padding: 1.25rem 1.25rem 1.1rem;
|
|
}
|
|
h2 { margin: 0 0 0.35rem; font-size: 1.15rem; font-weight: 600; }
|
|
.caption { margin: 0 0 1rem; color: var(--muted); max-width: 75ch; }
|
|
details { margin: -0.6rem 0 1rem; color: var(--muted); font-size: 0.9rem; }
|
|
details summary { cursor: pointer; color: var(--accent); }
|
|
details p { margin: 0.5rem 0 0; max-width: 75ch; }
|
|
.pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
|
figure { margin: 0; }
|
|
figure img {
|
|
width: 100%; aspect-ratio: 16 / 9; object-fit: contain; display: block;
|
|
background: #000; border-radius: 8px; border: 1px solid var(--line); cursor: zoom-in;
|
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
|
}
|
|
figure img:hover { border-color: var(--accent); transform: translateY(-2px); }
|
|
figcaption { margin-top: 0.4rem; font-size: 0.85rem; color: var(--muted); }
|
|
figcaption b { color: var(--text); font-weight: 600; }
|
|
.missing {
|
|
aspect-ratio: 16 / 9; display: grid; place-items: center; border-radius: 8px;
|
|
border: 1px dashed var(--line); color: var(--muted); font-size: 0.9rem;
|
|
}
|
|
#view {
|
|
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.93); display: none;
|
|
align-items: center; justify-content: center; flex-direction: column; gap: 0.75rem;
|
|
cursor: zoom-out; padding: 1rem;
|
|
}
|
|
#view.open { display: flex; }
|
|
#view img { max-width: 100%; max-height: calc(100vh - 4rem); }
|
|
#view div { color: var(--muted); font-size: 0.9rem; }
|
|
[hidden] { display: none !important; }
|
|
nav { max-width: 1400px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 0.5rem; }
|
|
nav a {
|
|
color: var(--muted); text-decoration: none; padding: 0.45rem 1rem; border-radius: 999px;
|
|
border: 1px solid var(--line);
|
|
}
|
|
nav a.on { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
|
.movie video, .movie .live {
|
|
width: 100%; aspect-ratio: 16 / 9; display: block; background: #000;
|
|
border-radius: 8px; border: 1px solid var(--line);
|
|
}
|
|
.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 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.
|
|
TABS.forEach((t) => { document.getElementById(t + '-tab').hidden = t !== name; });
|
|
document.querySelectorAll('nav a').forEach(
|
|
(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 = name === 'movie' ? live.dataset.src : '';
|
|
}
|
|
window.addEventListener('hashchange', tab);
|
|
tab();
|
|
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');
|
|
}
|
|
// 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(); }
|
|
});
|
|
"""
|
|
|
|
|
|
def caption(scene):
|
|
"""The paragraphs of the comment at the top of `scene`, leaving out the example
|
|
commands."""
|
|
paragraphs, lines = [], []
|
|
for line in scene.read_text().splitlines():
|
|
if not line.startswith("#"):
|
|
break
|
|
text = line[1:]
|
|
if text.strip().startswith("resolved render"):
|
|
continue
|
|
if text.strip():
|
|
lines.append(text.strip())
|
|
elif lines:
|
|
paragraphs.append(" ".join(lines))
|
|
lines = []
|
|
if lines:
|
|
paragraphs.append(" ".join(lines))
|
|
return paragraphs
|
|
|
|
|
|
def movie_section(host, live_port):
|
|
movie = GALLERY / "movie.mp4"
|
|
if movie.is_file():
|
|
stamp = int(movie.stat().st_mtime)
|
|
size = movie.stat().st_size / 1e6
|
|
recorded = (
|
|
f'<video src="/movie.mp4?{stamp}" controls loop playsinline></video>'
|
|
f'<p class="caption"><a class="download" href="/movie.mp4?{stamp}" '
|
|
f'download="resolved-movie.mp4">Download the MP4</a> ({size:.1f} MB)</p>'
|
|
)
|
|
else:
|
|
recorded = (
|
|
'<p class="caption">No movie yet: run scripts/make-gallery.sh, or '
|
|
"<code>cargo run --release -p resolved-showcase -- --out gallery/movie.mp4</code>"
|
|
".</p>"
|
|
)
|
|
stream = f"http://{host}:{live_port}/stream"
|
|
return f"""<section class="movie"><h2>The movie</h2>
|
|
<p class="caption">An inspector holds station 120 m behind and above a Landsat 8 model
|
|
705 km up; the target is propagated by astrokit with J2 and everything is rendered at
|
|
lighting L1 and camera C1. The camera drifts on a small natural relative orbit around
|
|
its station and sets its exposure frame by frame. Time runs fast over the daylit Earth, slows as the
|
|
Moon rises over the limb ahead, and slows almost to real time as the spacecraft passes
|
|
into Earth's shadow.</p>{recorded}</section>
|
|
<section class="movie"><h2>Live</h2>
|
|
<p class="caption">Rendered in real time on this machine's GPU and streamed as it is
|
|
made. Start it with
|
|
<code>cargo run --release -p resolved-showcase -- --serve 0.0.0.0:{live_port}</code>;
|
|
it renders only while this tab is open.</p>
|
|
<img id="live" class="live" data-src="{html.escape(stream)}" alt="live stream (not running)">
|
|
</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 = []
|
|
count = 0
|
|
for name in names:
|
|
scene = SCENES / f"{name}.toml"
|
|
paragraphs = caption(scene) if scene.is_file() else []
|
|
figures = []
|
|
for tier in TIERS:
|
|
image = GALLERY / f"{name}-{tier}.png"
|
|
if image.is_file():
|
|
count += 1
|
|
stamp = int(image.stat().st_mtime)
|
|
alt = f"{name}, lighting {tier}, camera {CAMERA}"
|
|
figures.append(
|
|
f'<figure><img src="/img/{name}-{tier}.png?{stamp}" '
|
|
f'alt="{html.escape(alt)}" loading="lazy">'
|
|
f"<figcaption><b>{tier}</b> lighting, camera {CAMERA}</figcaption></figure>"
|
|
)
|
|
else:
|
|
figures.append(
|
|
f'<figure><div class="missing">no {tier} render</div>'
|
|
f"<figcaption><b>{tier}</b> lighting, camera {CAMERA}</figcaption></figure>"
|
|
)
|
|
first = html.escape(paragraphs[0]) if paragraphs else ""
|
|
more = ""
|
|
if len(paragraphs) > 1:
|
|
body = "".join(f"<p>{html.escape(p)}</p>" for p in paragraphs[1:])
|
|
more = f"<details><summary>More</summary>{body}</details>"
|
|
cards.append(
|
|
f"<section><h2>{html.escape(name)}</h2>"
|
|
f'<p class="caption">{first}</p>{more}'
|
|
f'<div class="pair">{"".join(figures)}</div></section>'
|
|
)
|
|
if not cards:
|
|
cards.append(
|
|
"<section><h2>No images yet</h2><p class=\"caption\">"
|
|
"Run scripts/make-gallery.sh, then reload.</p></section>"
|
|
)
|
|
newest = max((p.stat().st_mtime for p in GALLERY.glob("*.png")), default=None)
|
|
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(newest)) if newest else "never"
|
|
return f"""<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>resolved gallery</title><style>{STYLE}</style></head>
|
|
<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><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>"""
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
path = unquote(self.path.split("?", 1)[0])
|
|
if path in ("/", "/index.html"):
|
|
host = (self.headers.get("Host") or "localhost").rsplit(":", 1)[0]
|
|
body = page(host, self.server.live_port).encode()
|
|
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.
|
|
image = GALLERY / 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())
|
|
else:
|
|
self.send(404, "text/plain", b"not found")
|
|
|
|
def send_movie(self, movie):
|
|
"""The movie, or the byte range asked for: browsers seek in a video by range."""
|
|
data = movie.read_bytes()
|
|
wanted = self.headers.get("Range", "")
|
|
if wanted.startswith("bytes="):
|
|
first, _, last = wanted[len("bytes="):].partition("-")
|
|
start = int(first) if first else max(len(data) - int(last), 0)
|
|
end = int(last) if first and last else len(data) - 1
|
|
end = min(end, len(data) - 1)
|
|
part = data[start:end + 1]
|
|
self.send_response(206)
|
|
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
|
|
else:
|
|
part = data
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "video/mp4")
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header("Content-Length", str(len(part)))
|
|
self.end_headers()
|
|
self.wfile.write(part)
|
|
|
|
def send(self, status, kind, body):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", kind)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
|
|
def lan_address():
|
|
"""This machine's address on the network: the source of a route outward. A UDP
|
|
connect sends nothing."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
try:
|
|
s.connect(("10.255.255.255", 1))
|
|
return s.getsockname()[0]
|
|
except OSError:
|
|
return "127.0.0.1"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--port", type=int, default=8000)
|
|
parser.add_argument("--live-port", type=int, default=8010,
|
|
help="the port resolved-movie --serve streams on")
|
|
args = parser.parse_args()
|
|
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
|
|
server.live_port = args.live_port
|
|
print(f"serving {GALLERY} at http://{lan_address()}:{args.port}/ (Ctrl+C to stop)", flush=True)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|