120 lines
4.8 KiB
Python
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()
|