- pipeline/dump_bird.mjs: imports web/js/world/magpie.js UNMODIFIED (bare 'three' resolved to the repo's vendored build through a node resolve hook) and dumps birdGeometry() — so the A/B is against Lane B's actual bird, not a port. MEASURED 182 tris / 216 verts. --sim walks a player past a territory on magpie.js's own clock: drawn 54.1% of frames, and OF THOSE perched 80.5% / swooping 7.7% / returning 11.8%. - pipeline/bird_to_glb.py: wraps the dump as a GLB in E's frame (head +Z, +Y up) so the identical render_views.py rig shoots both; --fold applies the perch pose (x x 0.42). glb_stat re-measures 182. - pipeline/render_views.py --noemit: strips emission from BOTH candidates. normalize.py's emissiveFactor 0.28 is albedo-MODULATED; a vertex-coloured mesh cannot express that in glTF and Blender writes a flat 0.28 that lifts a black bird to grey. Off both sides or the picture lies. - pipeline/view_sheet.py --ab: the strip as a GRID (one line per candidate, stacked) plus two linear motion-blur blocks at 25% and 100% of the bird's width, and a wrapped notes footer. - docs/shots/laneE/r39_magpie_ab.png — the sheet Fable rules on. Recommendation in LANE_E_NOTES; picture first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.5 KiB
Python
188 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
||
"""Compose render_views.py output into one labelled contact sheet + a distance strip.
|
||
|
||
PY=~/Documents/MODELBEAST/venvs/mflux/bin/python
|
||
$PY pipeline/view_sheet.py OUT.png "title" "row label" a.png.views.txt ["row 2" b...txt] ...
|
||
|
||
One row of views per input (each composited over sky — bird-against-sky is the contrast that
|
||
matters), then a strip that re-samples the swoop / flank / rear views to 64 / 48 / 32 px and
|
||
magnifies them NEAREST: a 0.36 m bird at 3-10 m on a 1080-high screen is 50-100 px, so 64 px is
|
||
the honest test of whether a marking reads and 32 px is the pessimistic one.
|
||
|
||
`--ab` (R39, the two magpies) lays the distance strip out as a GRID — one line per candidate, the
|
||
same view and the same pixel size stacked vertically — because a single 27-cell line is a list and
|
||
what a ruling needs is a column you can look down. It then repeats that grid under linear MOTION
|
||
BLUR, since the bird is seen for ~1.2 s crossing peripheral vision and a marking that only reads on
|
||
a still is not a marking the player ever sees.
|
||
"""
|
||
import sys
|
||
import textwrap
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw
|
||
|
||
SKY = (158, 184, 219)
|
||
PAD, LBL = 10, 16
|
||
STRIP = ("swoop", "flank", "rear34")
|
||
SIZES = (64, 48, 32)
|
||
CELL = 112
|
||
GUTTER = 150
|
||
|
||
|
||
def over_sky(p, bg=SKY):
|
||
im = Image.open(p).convert("RGBA")
|
||
out = Image.new("RGB", im.size, bg)
|
||
out.paste(im, (0, 0), im)
|
||
a = im.getchannel("A")
|
||
bb = a.getbbox() # the SUBJECT's extent — motion blur is scaled to the bird
|
||
out.info["subject"] = bb or (0, 0, im.width, im.height)
|
||
return out
|
||
|
||
|
||
def load(path):
|
||
views = {}
|
||
for line in open(path):
|
||
k, v = line.rstrip("\n").split("\t")
|
||
if k != "LABEL":
|
||
views[k] = over_sky(v)
|
||
return views
|
||
|
||
|
||
def motion_blur(im, frac):
|
||
"""Linear horizontal smear whose length is `frac` × the SUBJECT's on-screen width.
|
||
|
||
A true average of N sub-frame positions, not a gaussian: that is what a shutter integrates and
|
||
it is what decides whether a 5 cm white wing bar survives being dragged across its own body.
|
||
"""
|
||
bb = im.info.get("subject", (0, 0, im.width, im.height))
|
||
span = max(1, int(round(frac * (bb[2] - bb[0]))))
|
||
if span < 2:
|
||
return im
|
||
pad = span // 2 + 2
|
||
a = np.asarray(Image.new("RGB", (im.width + 2 * pad, im.height), SKY), dtype=np.float32).copy()
|
||
a[:, pad:pad + im.width] = np.asarray(im, dtype=np.float32)
|
||
n = min(96, span)
|
||
acc = np.zeros_like(a)
|
||
for i in range(n):
|
||
off = int(round((i / (n - 1) - 0.5) * span))
|
||
acc += np.roll(a, off, axis=1)
|
||
acc /= n
|
||
out = Image.fromarray(acc[:, pad:pad + im.width].astype("uint8"))
|
||
out.info["subject"] = bb
|
||
return out
|
||
|
||
|
||
def ab_sheet(out_path, title, rows, notes):
|
||
"""One line per candidate, stacked, still and then moving. Built for a ruling, not a gallery."""
|
||
w, h = next(iter(rows[0][1].values())).size
|
||
ncol = max(len(v) for _, v in rows)
|
||
cols = [(n, s) for n in STRIP for s in SIZES]
|
||
blocks = [("AT THE SIZE IT IS ACTUALLY SEEN — still (rendered px, magnified NEAREST)", 0.0),
|
||
("MOVING — smear = 25% of the bird's width (the approach: ~0.11 m/frame at 60 fps)", 0.25),
|
||
("MOVING — smear = 100% of the bird's width (the pass: swoop covers 34 m in 1.25 s "
|
||
"⇒ 0.45 m/frame, ~1 body length)", 1.0)]
|
||
W = max(PAD + ncol * (w + PAD), GUTTER + len(cols) * (CELL + PAD) + PAD)
|
||
H = (24 + len(rows) * (h + LBL + PAD)
|
||
+ len(blocks) * (LBL + 14 + len(rows) * (CELL + LBL + 4)) + PAD
|
||
+ LBL * (sum(max(1, len(l) // 300 + 1) for l in notes) * 3 + 2))
|
||
sheet = Image.new("RGB", (W, H), (24, 24, 26))
|
||
d = ImageDraw.Draw(sheet)
|
||
d.text((PAD, 5), title, fill=(240, 240, 240))
|
||
y = 24
|
||
for label, views in rows:
|
||
d.text((PAD, y), label, fill=(250, 210, 120))
|
||
y += LBL
|
||
for i, (n, im) in enumerate(views.items()):
|
||
x = PAD + i * (w + PAD)
|
||
sheet.paste(im, (x, y))
|
||
d.text((x + 4, y + 2), n, fill=(40, 40, 40))
|
||
y += h + PAD
|
||
for head, frac in blocks:
|
||
d.text((PAD, y), head, fill=(250, 210, 120))
|
||
y += LBL
|
||
for i, (n, s) in enumerate(cols):
|
||
d.text((GUTTER + i * (CELL + PAD) + 3, y), f"{n} {s}px", fill=(175, 175, 175))
|
||
y += 14
|
||
for label, views in rows:
|
||
d.text((PAD, y + CELL // 2 - 6), label[:22], fill=(225, 225, 225))
|
||
for i, (n, s) in enumerate(cols):
|
||
if n not in views:
|
||
continue
|
||
src = motion_blur(views[n], frac) if frac else views[n]
|
||
sheet.paste(src.resize((s, s), Image.LANCZOS).resize((CELL, CELL), Image.NEAREST),
|
||
(GUTTER + i * (CELL + PAD), y))
|
||
y += CELL + LBL + 4
|
||
y += 4
|
||
for line in notes:
|
||
for part in textwrap.wrap(line, width=max(60, (W - 2 * PAD) // 6)) or [""]:
|
||
d.text((PAD, y), part, fill=(190, 190, 190))
|
||
y += LBL
|
||
sheet = sheet.crop((0, 0, W, min(sheet.height, y + PAD))) # the height estimate over-reserves
|
||
sheet.save(out_path)
|
||
print(f"ab sheet -> {out_path} {sheet.size}")
|
||
|
||
|
||
def main():
|
||
out_path, title = sys.argv[1], sys.argv[2]
|
||
rows = []
|
||
args = sys.argv[3:]
|
||
extra = None
|
||
notes = []
|
||
if "--note" in args: # --note "line" ["line" ...] : footer, always last
|
||
i = args.index("--note")
|
||
notes = args[i + 1:]
|
||
args = args[:i]
|
||
if "--ab" in args:
|
||
args.remove("--ab")
|
||
for i in range(0, len(args), 2):
|
||
rows.append((args[i], load(args[i + 1])))
|
||
return ab_sheet(out_path, title, rows, notes)
|
||
if "--extra" in args: # --extra IMG.png "caption": pasted under the sheet
|
||
i = args.index("--extra")
|
||
extra = (args[i + 1], args[i + 2])
|
||
args = args[:i]
|
||
for i in range(0, len(args), 2):
|
||
rows.append((args[i], load(args[i + 1])))
|
||
w, h = next(iter(rows[0][1].values())).size
|
||
ncol = max(len(v) for _, v in rows)
|
||
n_strip = sum(len([n for n in STRIP if n in v]) * len(SIZES) for _, v in rows)
|
||
ex = Image.open(extra[0]).convert("RGB") if extra else None
|
||
W = max(PAD + ncol * (w + PAD), PAD + n_strip * (CELL + PAD), (ex.width + 2 * PAD) if ex else 0)
|
||
H = 20 + len(rows) * (h + LBL + PAD) + LBL + CELL + 18 + PAD
|
||
if ex:
|
||
H += LBL + ex.height + PAD
|
||
sheet = Image.new("RGB", (W, H), (24, 24, 26))
|
||
d = ImageDraw.Draw(sheet)
|
||
d.text((PAD, 4), title, fill=(240, 240, 240))
|
||
y = 22
|
||
for label, views in rows:
|
||
d.text((PAD, y), label, fill=(250, 210, 120))
|
||
y += LBL
|
||
for i, (n, im) in enumerate(views.items()):
|
||
x = PAD + i * (w + PAD)
|
||
sheet.paste(im, (x, y))
|
||
d.text((x + 4, y + 2), n, fill=(40, 40, 40))
|
||
y += h + PAD
|
||
d.text((PAD, y), "AT THE SIZE IT IS ACTUALLY SEEN (rendered px, magnified NEAREST):",
|
||
fill=(250, 210, 120))
|
||
y += LBL
|
||
i = 0
|
||
for label, views in rows:
|
||
for n in STRIP:
|
||
if n not in views:
|
||
continue
|
||
for s in SIZES:
|
||
x = PAD + i * (CELL + PAD)
|
||
sheet.paste(views[n].resize((s, s), Image.LANCZOS).resize((CELL, CELL), Image.NEAREST),
|
||
(x, y))
|
||
d.text((x + 3, y + CELL + 2), f"{label[:9]} {n} {s}px", fill=(175, 175, 175))
|
||
i += 1
|
||
if ex:
|
||
y += CELL + 18
|
||
d.text((PAD, y), extra[1], fill=(250, 210, 120))
|
||
sheet.paste(ex, (PAD, y + LBL))
|
||
sheet.save(out_path)
|
||
print(f"sheet -> {out_path} {sheet.size}")
|
||
|
||
|
||
main()
|