PROCITY/pipeline/view_sheet.py
m3ultra a569f08b14 Lane E R39 (1/n): THE MAGPIE, SETTLED ON A PICTURE — B's own birdGeometry() dumped vertex-for-vertex (182 tris, not 154), rendered against E's tinted GLB at 64/48/32 px, still and under motion blur
- 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>
2026-08-03 20:35:00 +10:00

188 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()