Files
aetherbound-guild/tools/prepare_hub001_assets.py
T

202 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Prepare traceable HUB-001 assets from approved existing sources."""
from __future__ import annotations
import hashlib
import json
import shutil
import random
from pathlib import Path
from PIL import Image, ImageDraw
from process_green_grid_assets import remove_green
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs/design-authority/pages/HUB-001-guild-desk/reconstruction/v001/assets"
def sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def clean_green(source: Path, target: Path) -> None:
image = remove_green(Image.open(source).convert("RGBA"))
target.parent.mkdir(parents=True, exist_ok=True)
image.save(target, optimize=True)
def clean_rail(source: Path, target: Path, left: int, center_start: int, right_start: int) -> None:
"""Remove fixed icon slots from a rail so its center can stretch cleanly."""
source_image = remove_green(Image.open(source).convert("RGBA"))
pieces = [
source_image.crop((0, 0, left, source_image.height)),
source_image.crop((center_start, 0, right_start, source_image.height)),
source_image.crop((right_start, 0, source_image.width, source_image.height)),
]
result = Image.new("RGBA", (sum(piece.width for piece in pieces), source_image.height), (0, 0, 0, 0))
x = 0
for piece in pieces:
result.alpha_composite(piece, (x, 0))
x += piece.width
target.parent.mkdir(parents=True, exist_ok=True)
result.save(target, optimize=True)
def clean_board(source: Path, target: Path) -> None:
image = remove_green(Image.open(source).convert("RGBA"))
# Repaint only the writable interiors. A soft procedural parchment texture
# removes pseudo-writing while retaining the illustrated frame and pins.
random.seed(1721)
texture = Image.new("RGBA", image.size, (228, 191, 133, 255))
pixels = texture.load()
for y in range(texture.height):
for x in range(texture.width):
grain = random.randint(-5, 5)
shade = round(4 * (y / texture.height))
pixels[x, y] = (max(0, 228 + grain - shade), max(0, 191 + grain - shade), max(0, 133 + grain - shade), 255)
mask = Image.new("L", image.size, 0)
mask_draw = ImageDraw.Draw(mask)
# Main sheet, left slips, right slips and lower receipt. Insets preserve
# the original paper edge, pins and frame hardware.
polygons = [
[(176, 220), (389, 220), (391, 438), (177, 438)],
[(109, 235), (148, 235), (148, 276), (109, 276)],
[(94, 335), (138, 335), (138, 410), (94, 410)],
[(401, 194), (458, 194), (458, 272), (401, 272)],
[(422, 307), (487, 307), (487, 405), (422, 405)],
[(346, 438), (394, 438), (394, 480), (346, 480)],
]
for polygon in polygons:
mask_draw.polygon(polygon, fill=255)
image.alpha_composite(Image.composite(texture, Image.new("RGBA", image.size), mask))
# Remove a small keyed-background remnant at the lower-right crop corner;
# preserve the board's illustrated feet and hardware inside the silhouette.
alpha = image.getchannel("A")
for y in range(image.height - 18, image.height):
for x in range(image.width - 18, image.width):
alpha.putpixel((x, y), 0)
image.putalpha(alpha)
target.parent.mkdir(parents=True, exist_ok=True)
image.save(target, optimize=True)
def copy_source(source: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def inspect_image(path: Path) -> dict[str, object]:
image = Image.open(path).convert("RGBA")
width, height = image.size
pixels = list(image.getdata())
visible = [(r, g, b, a) for r, g, b, a in pixels if a > 0]
green_spill = sum(1 for r, g, b, _ in visible if g - r > 50 and g - b > 50)
corners = [
image.getpixel((0, 0))[3],
image.getpixel((width - 1, 0))[3],
image.getpixel((0, height - 1))[3],
image.getpixel((width - 1, height - 1))[3],
]
return {
"dimensions": [width, height],
"mode": Image.open(path).mode,
"visible_pixels": len(visible),
"alpha_bound": list(image.getchannel("A").getbbox() or ()),
"corner_alpha": corners,
"green_spill_pixels": green_spill,
}
def main() -> None:
sources = {
"guild_world_background": ROOT / "runtime/assets/backgrounds/guild_master_b_background.png",
"recruit_a_unit": ROOT / "runtime/assets/entry/recruit_stickers/pf_b05-512.png",
"recruit_b_unit": ROOT / "runtime/assets/entry/recruit_stickers/pf_b03-512.png",
"top_rail_shell": ROOT / "runtime/assets/entry/guild_master_b_ui/top_rail.png",
"bottom_destination_rail": ROOT / "runtime/assets/entry/guild_master_b_ui/bottom_rail.png",
"resource_badge_frame": ROOT / "runtime/assets/entry/guild_master_b_ui/resource_badge.png",
"guild_crest_source": ROOT / "runtime/assets/entry/guild_master_b_ui/top_rail.png",
"contract_board_source": ROOT / "runtime/assets/entry/guild_master_b_ui/contract_kiosk.png",
}
for source in sources.values():
if not source.is_file():
raise SystemExit(f"missing source: {source}")
OUT.mkdir(parents=True, exist_ok=True)
outputs: dict[str, Path] = {}
copy_source(sources["guild_world_background"], OUT / "guild-world-background.png")
outputs["guild_world_background"] = OUT / "guild-world-background.png"
copy_source(sources["recruit_a_unit"], OUT / "recruit-a-unit.png")
outputs["recruit_a_unit"] = OUT / "recruit-a-unit.png"
copy_source(sources["recruit_b_unit"], OUT / "recruit-b-unit.png")
outputs["recruit_b_unit"] = OUT / "recruit-b-unit.png"
clean_rail(sources["top_rail_shell"], OUT / "top-rail-shell-clean.png", 220, 700, 1740)
outputs["top_rail_shell"] = OUT / "top-rail-shell-clean.png"
clean_rail(sources["bottom_destination_rail"], OUT / "bottom-destination-rail-clean.png", 110, 615, 1540)
outputs["bottom_destination_rail"] = OUT / "bottom-destination-rail-clean.png"
clean_green(sources["resource_badge_frame"], OUT / "resource-badge-frame.png")
outputs["resource_badge_frame"] = OUT / "resource-badge-frame.png"
crest = remove_green(Image.open(sources["guild_crest_source"]).convert("RGBA"))
crest = crest.crop((0, 0, 220, 266))
crest.save(OUT / "guild-crest.png", optimize=True)
outputs["guild_crest"] = OUT / "guild-crest.png"
clean_board(sources["contract_board_source"], OUT / "contract-board-clean.png")
outputs["contract_board"] = OUT / "contract-board-clean.png"
processing = {
"guild_world_background": "copied unchanged with provenance",
"recruit_a_unit": "copied unchanged from the approved unified Recruit family",
"recruit_b_unit": "copied unchanged from the approved unified Recruit family",
"top_rail_shell": "green chroma removal; fixed slot rings removed; clean center prepared for horizontal stretch",
"bottom_destination_rail": "green chroma removal; fixed destination buttons removed; clean center prepared for horizontal stretch",
"resource_badge_frame": "green chroma removal; blank frame retained",
"guild_crest": "green chroma removal and semantic crop from the rail source",
"contract_board": "green chroma removal plus repaint of all writable paper interiors",
}
records = []
for asset_id, output in outputs.items():
source = sources.get(asset_id, sources["guild_crest_source"] if asset_id == "guild_crest" else sources["contract_board_source"] if asset_id == "contract_board" else output)
records.append({
"id": asset_id,
"path": output.relative_to(ROOT).as_posix(),
"source": source.relative_to(ROOT).as_posix(),
"source_sha256": sha(source),
"output_sha256": sha(output),
"processing": processing[asset_id],
"text_policy": "no baked labels, values, pseudo-writing or placeholder lines",
"verification": inspect_image(output),
})
transparent_ids = set(outputs) - {"guild_world_background"}
violations = []
for record in records:
verification = record["verification"]
if record["id"] in transparent_ids and any(verification["corner_alpha"]):
violations.append(f"{record['id']}: opaque corner")
if record["id"] in transparent_ids and verification["green_spill_pixels"]:
violations.append(f"{record['id']}: green spill")
if violations:
raise SystemExit("ABG_HUB001_ASSET_VERIFY_FAILED " + "; ".join(violations))
manifest = {
"schema_version": 1,
"page_id": "HUB-001",
"version": "v001",
"state": "ASSET_REVIEW",
"source_policy": "Reused approved/provenance assets only; no new paid generation call was needed for this package.",
"assets": records,
"runtime_icon_policy": "Pressure, Coin, Company and Settings glyphs remain engine-owned over the approved blank frames.",
"review_status": "awaiting_owner_asset_review",
"verification": {"ok": True, "transparent_assets": len(transparent_ids), "violations": []},
}
manifest_path = OUT.parent / "asset-manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"ABG_HUB001_ASSETS_OK assets={len(records)} manifest={manifest_path}")
if __name__ == "__main__":
main()