KAZIK/tools/generators/pack_jackbox.py

120 lines
4.8 KiB
Python

"""Offline packaging of Jackbox / Jukebox art for the MASM32 assembly renderer.
Processes high-resolution source images, removes studio grey background with clean
alpha masking and anti-aliasing, aligns 3D cabinet surfaces (Front, Side, Back),
and outputs binary packs (ANM1).
"""
import math
from pathlib import Path
import struct
import cv2
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "assets/sprites/jackbox"
OUT = ROOT / "assets/animations/hall"
def clean_cutout(img_bgr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Segment studio grey background using connected components from image borders."""
h, w = img_bgr.shape[:2]
arr = img_bgr.astype(np.float32)
# Detect neutral studio grey pixels: low saturation and mid-low brightness
color_diff = np.maximum(
np.abs(arr[:, :, 0] - arr[:, :, 1]),
np.maximum(np.abs(arr[:, :, 1] - arr[:, :, 2]), np.abs(arr[:, :, 0] - arr[:, :, 2]))
)
brightness = np.mean(arr, axis=2)
is_bg_cand = (color_diff < 10) & (brightness >= 18) & (brightness <= 65)
# Seed from all 4 borders
seed_mask = np.zeros((h, w), dtype=np.uint8)
seed_mask[0, :] = is_bg_cand[0, :]
seed_mask[-1, :] = is_bg_cand[-1, :]
seed_mask[:, 0] = is_bg_cand[:, 0]
seed_mask[:, -1] = is_bg_cand[:, -1]
num_labels, labels = cv2.connectedComponents(is_bg_cand.astype(np.uint8))
border_labels = np.unique(labels[seed_mask == 1])
border_labels = border_labels[border_labels != 0]
is_bg = np.isin(labels, border_labels)
clean_fg = (~is_bg).astype(np.uint8)
# Fill internal holes in foreground if any
num_fg, fg_labels = cv2.connectedComponents(clean_fg)
fg_sizes = np.bincount(fg_labels.flatten())
fg_sizes[0] = 0
main_fg_label = np.argmax(fg_sizes)
clean_fg = (fg_labels == main_fg_label).astype(np.uint8)
# Anti-aliased alpha falloff via distance transform
dist_in = cv2.distanceTransform(clean_fg, cv2.DIST_L2, 3)
dist_out = cv2.distanceTransform(1 - clean_fg, cv2.DIST_L2, 3)
alpha = np.clip((dist_in - dist_out + 0.5) * 255.0, 0, 255).astype(np.uint8)
bgra = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2BGRA)
bgra[:, :, 3] = alpha
return bgra, clean_fg
def pack_bgra(path: Path, frames: list[np.ndarray], w: int, h: int):
"""Pack an array of (H, W, 4) BGRA numpy uint8 images into an ANM1 binary pack."""
raw = b"".join(f.tobytes() for f in frames)
path.parent.mkdir(parents=True, exist_ok=True)
header = struct.pack("<4s4I", b"ANM1", w, h, len(frames), len(raw))
path.write_bytes(header + raw)
print(f"Packed {len(frames)} frame(s) ({w}x{h}) into {path.name}: {len(raw)} bytes")
def main():
front_path = SOURCE / "Jackbox_front.jpg"
side_path = SOURCE / "Jackbox_side.jpg"
ui_path = SOURCE / "Jackbox_UI.jpg"
assert front_path.exists(), f"Missing {front_path}"
assert side_path.exists(), f"Missing {side_path}"
assert ui_path.exists(), f"Missing {ui_path}"
print("Extracting and segmenting Jukebox textures...")
# 1. Front Texture
front_bgr = cv2.imread(str(front_path))
front_bgra, front_fg = clean_cutout(front_bgr)
front_tex = cv2.resize(front_bgra, (256, 512), interpolation=cv2.INTER_LANCZOS4)
# 2. Side Texture: clean, crop margins, flip horizontally so back is x=0 and front is x=255
side_bgr = cv2.imread(str(side_path))
side_bgra, side_fg = clean_cutout(side_bgr)
# Crop to actual jukebox silhouette (remove studio floor and top empty margins)
side_cropped = side_bgra[80:2912, 0:1350]
side_flipped = cv2.flip(side_cropped, 1)
side_tex = cv2.resize(side_flipped, (256, 512), interpolation=cv2.INTER_LANCZOS4)
# 3. Rear Texture: authentic dark walnut cabinet panel with ventilation slots and arch top
rear_tex = np.zeros((512, 256, 4), dtype=np.uint8)
for y in range(512):
grain = int(math.sin(y * 0.2) * 5 + math.sin(y * 0.05) * 8)
rear_tex[y, :, 0] = np.clip(20 + grain, 0, 255) # B
rear_tex[y, :, 1] = np.clip(28 + grain + 2, 0, 255) # G
rear_tex[y, :, 2] = np.clip(45 + grain + 5, 0, 255) # R
for y in range(120, 220, 10):
rear_tex[y:y+3, 40:216, :3] = 12 # Dark ventilation slots
rear_tex[:, :, 3] = cv2.resize(front_bgra[:, :, 3], (256, 512), interpolation=cv2.INTER_LANCZOS4)
# Pack 3D world frames: Frame 0 = Front, Frame 1 = Side, Frame 2 = Rear
pack_bgra(OUT / "jackbox-v1.bin", [front_tex, side_tex, rear_tex], 256, 512)
# 4. Screen UI Pack: 640x480 BGRA
ui_bgr = cv2.imread(str(ui_path))
ui_bgra = cv2.cvtColor(ui_bgr, cv2.COLOR_BGR2BGRA)
ui_tex = cv2.resize(ui_bgra, (640, 480), interpolation=cv2.INTER_LANCZOS4)
pack_bgra(OUT / "jackbox-ui-v1.bin", [ui_tex], 640, 480)
print("Jukebox asset packaging complete.")
if __name__ == "__main__":
main()