127 sprites had been generated and about eight had ever been looked at. The pipeline will happily render the wrong object, confidently and well-lit, and no assertion can tell — barrier and flightCase are both "a grey box with edges" as far as any test goes. tools/gen/contact_sheet.py puts every shipped sprite on one page at integer upscale on the venue's own floor colour. It immediately showed about twenty props reading as pale blobs. First instinct was that the vertex baker had lost the albedo. That was wrong: sat=0 on ashUrn, marbleSink and pillar is honest, because stainless and marble and concrete really are grey. The fault was BRIGHTNESS. Props that read sit at median luminance 23-123 (patioHeater 23, arcade 45, poolTable 53, cdj 68); the blobs were 150-190. The venue is painted at ~35 under a 50% black sheet and props are lit by the room's own LIGHTS, so a prop arriving at 180 doesn't read as a bright object — it reads as self-illuminated. Every prop that worked before worked by accident of subject matter; the first pale subjects exposed the gap. props_import --dim scales RGB until median opaque luminance is <=110. Only ever darkens, so anything already in band passes through untouched. Fixed 48 sprites with no regeneration at all — the 512px Blender renders were still in art_incoming, which is exactly why that directory is kept. Also: poster7 shipped with 29% of its pixels and poster9 with 42%, because the near-black-to-alpha pass was eating the dark half of a dark poster. Posters are rectangular plates on a wall, so 4-9 now keep their background and fill the frame. And cf_flux --probe was reporting EXHAUSTED on an account with a full 10,000 neurons: the probe prompt was "a grey square", Cloudflare's safety filter refused it as NSFW, and every non-429 fell through to SystemExit. A health check that can't tell "refused" from "empty" will eventually call a healthy system dead. Gate: lint clean, build clean, 821 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
71 lines
2.8 KiB
Python
71 lines
2.8 KiB
Python
#!/usr/bin/env python3
|
|
"""contact_sheet.py — every shipped sprite on one page, for judging.
|
|
|
|
python3 tools/gen/contact_sheet.py [out.png] [--filter substr]
|
|
|
|
Built because 127 sprites had been generated and only a handful had ever been
|
|
LOOKED at. The pipeline is happy to produce a confident, well-lit render of the
|
|
wrong object, and no test can tell — `barrier` and `flightCase` are both "a grey
|
|
box with edges" as far as any assertion goes.
|
|
|
|
Sprites are shown at an integer upscale on the game's own dark floor colour,
|
|
which is the honest way to see them: SPACES.md's rule is to judge at ship size
|
|
rather than at render size, so this deliberately does NOT show the 512px render.
|
|
The point is to catch sprites that are unrecognisable or wrongly cut out, not to
|
|
pixel-peep detail that never reaches the screen.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
PROPS = Path(__file__).resolve().parent.parent.parent / "public" / "props"
|
|
CELL_W, CELL_H = 108, 124
|
|
COLS = 12
|
|
LABEL_H = 14
|
|
# The venue floor colour from venueMap COLOURS.floor — sprites are judged against
|
|
# what they actually sit on, not against white.
|
|
BG = (34, 30, 43)
|
|
CELL_BG = (24, 21, 30)
|
|
|
|
|
|
def main() -> None:
|
|
out = Path(sys.argv[1]) if len(sys.argv) > 1 and not sys.argv[1].startswith("--") \
|
|
else Path("art_incoming/contact_sheet.png")
|
|
needle = sys.argv[sys.argv.index("--filter") + 1] if "--filter" in sys.argv else ""
|
|
|
|
names = sorted(p.stem for p in PROPS.glob("*.png") if needle in p.stem)
|
|
if not names:
|
|
sys.exit("no sprites matched")
|
|
rows = (len(names) + COLS - 1) // COLS
|
|
sheet = Image.new("RGB", (COLS * CELL_W, rows * CELL_H), BG)
|
|
draw = ImageDraw.Draw(sheet)
|
|
|
|
for i, name in enumerate(names):
|
|
cx = (i % COLS) * CELL_W
|
|
cy = (i // COLS) * CELL_H
|
|
draw.rectangle([cx + 1, cy + 1, cx + CELL_W - 2, cy + CELL_H - LABEL_H - 2], fill=CELL_BG)
|
|
|
|
img = Image.open(PROPS / f"{name}.png").convert("RGBA")
|
|
room_w, room_h = CELL_W - 8, CELL_H - LABEL_H - 8
|
|
# Integer upscale only — a fractional resize would invent detail the game
|
|
# never shows and blur the pixel register these were quantised into.
|
|
scale = max(1, min(room_w // max(1, img.width), room_h // max(1, img.height)))
|
|
shown = img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
|
px = cx + (CELL_W - shown.width) // 2
|
|
py = cy + (CELL_H - LABEL_H - shown.height) // 2
|
|
sheet.paste(shown, (px, py), shown)
|
|
|
|
draw.text((cx + 3, cy + CELL_H - LABEL_H), f"{name} {img.width}x{img.height}",
|
|
fill=(150, 150, 165))
|
|
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
sheet.save(out)
|
|
print(f"{len(names)} sprites -> {out} ({sheet.width}x{sheet.height})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|