94 lines
2.9 KiB
Python
94 lines
2.9 KiB
Python
|
|
# /// script
|
||
|
|
# requires-python = ">=3.9"
|
||
|
|
# dependencies = ["pillow"]
|
||
|
|
# ///
|
||
|
|
"""
|
||
|
|
Generate routes/root/dist-strips.bin from routes/root/dist.png.
|
||
|
|
|
||
|
|
Splits the 512x512 grayscale Julia distance field into N horizontal bands,
|
||
|
|
re-encodes each band as its own WebP, and concatenates them in CENTER-OUT order
|
||
|
|
(the band the pan/zoom animation shows first) so a streaming client can upload
|
||
|
|
the visible region before the outer bands have finished downloading. WebP q90 is
|
||
|
|
~half the size of PNG with no visible loss after the color-band mapping.
|
||
|
|
|
||
|
|
File layout (little-endian):
|
||
|
|
u32 headerLen
|
||
|
|
bytes headerLen -- UTF-8 JSON: {fullW, fullH, bands:[{y,h,off,len}, ...]}
|
||
|
|
bytes payload -- concatenated band WebPs in the order listed in `bands`
|
||
|
|
(off/len are byte ranges within payload, stream order)
|
||
|
|
|
||
|
|
Re-run only when dist.png changes; the output is committed.
|
||
|
|
Usage: uv run tools/gen-strips.py
|
||
|
|
"""
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import struct
|
||
|
|
from PIL import Image
|
||
|
|
|
||
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
|
ROOT = os.path.dirname(HERE)
|
||
|
|
SRC = os.path.join(ROOT, "routes", "root", "dist.png")
|
||
|
|
OUT = os.path.join(ROOT, "routes", "root", "dist-strips.bin")
|
||
|
|
NBANDS = 8
|
||
|
|
WEBP_QUALITY = 90 # lossy; ~half of PNG, visually identical. Bump to 95 / use lossless if needed.
|
||
|
|
|
||
|
|
|
||
|
|
def center_out(n):
|
||
|
|
"""Band indices ordered from the middle outward: e.g. n=8 -> [4,3,5,2,6,1,7,0]."""
|
||
|
|
order = []
|
||
|
|
lo, hi = n // 2 - 1, n // 2
|
||
|
|
while lo >= 0 or hi < n:
|
||
|
|
if hi < n:
|
||
|
|
order.append(hi)
|
||
|
|
hi += 1
|
||
|
|
if lo >= 0:
|
||
|
|
order.append(lo)
|
||
|
|
lo -= 1
|
||
|
|
return order
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
img = Image.open(SRC).convert("L")
|
||
|
|
w, h = img.size
|
||
|
|
|
||
|
|
# Row boundaries (robust if h is not divisible by NBANDS).
|
||
|
|
bounds = [round(i * h / NBANDS) for i in range(NBANDS + 1)]
|
||
|
|
bands_by_index = []
|
||
|
|
for i in range(NBANDS):
|
||
|
|
y0, y1 = bounds[i], bounds[i + 1]
|
||
|
|
crop = img.crop((0, y0, w, y1))
|
||
|
|
buf = io.BytesIO()
|
||
|
|
crop.save(buf, format="WEBP", quality=WEBP_QUALITY, method=6)
|
||
|
|
bands_by_index.append((y0, y1 - y0, buf.getvalue()))
|
||
|
|
|
||
|
|
payload = bytearray()
|
||
|
|
meta = []
|
||
|
|
order = center_out(NBANDS)
|
||
|
|
for idx in order:
|
||
|
|
y, bh, png = bands_by_index[idx]
|
||
|
|
meta.append({"y": y, "h": bh, "off": len(payload), "len": len(png)})
|
||
|
|
payload += png
|
||
|
|
|
||
|
|
header = json.dumps(
|
||
|
|
{"fullW": w, "fullH": h, "bands": meta}, separators=(",", ":")
|
||
|
|
).encode("utf-8")
|
||
|
|
|
||
|
|
with open(OUT, "wb") as f:
|
||
|
|
f.write(struct.pack("<I", len(header)))
|
||
|
|
f.write(header)
|
||
|
|
f.write(payload)
|
||
|
|
|
||
|
|
total = 4 + len(header) + len(payload)
|
||
|
|
src_size = os.path.getsize(SRC)
|
||
|
|
print(f"{w}x{h} -> {NBANDS} bands, center-out order {order}")
|
||
|
|
print(
|
||
|
|
f"header {len(header)}B, payload {len(payload)}B, total {total}B "
|
||
|
|
f"({total / 1024:.1f} KB) vs dist.png {src_size}B ({src_size / 1024:.1f} KB)"
|
||
|
|
)
|
||
|
|
print(f"wrote {OUT}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|