202 lines
9.3 KiB
Python
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()
|