Files
heros_web/scripts/assemble-handpaint-shu-infantry-quality-sample.py

609 lines
26 KiB
Python

from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "docs" / "handpaint-batch2-shu-infantry-quality-restart-source-v3.png"
WORK_DIR = ROOT / "tmp" / "handpaint-shu-infantry-quality-sample"
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
DOCS_DIR = ROOT / "docs"
BATTLE_REFERENCE = DOCS_DIR / "handpaint-batch1-liu-guan-size-match-battle.png"
STEM = "unit-shu-infantry"
OUTPUT_PREFIX = "handpaint-batch2-shu-infantry-quality-restart-v3"
CONTACT_TITLE = "Shu Infantry Quality Restart Sample v3"
BEFORE_TITLE = "Shu Infantry Previous / Quality Restart Sample v3"
BEFORE_SCALE_LABEL = "Previous / v3 at battle scale"
REPORT_TITLE = "Shu Infantry Quality Restart Sample v3"
SAMPLE_LABEL = "shu sample"
COMPARE_STEMS = ["unit-liu-bei", "unit-guan-yu", "unit-zhang-fei", "unit-rebel", STEM]
COMPARE_LABELS = ["liu-bei", "guan-yu", "zhang-fei", "rebel", "shu-infantry"]
FRAME = 313
DIRECTIONS = ("south", "east", "north", "west")
BASE_FRAMES_PER_DIRECTION = 16
ACTION_COUNTS = {
"attack": 10,
"strategy": 8,
"item": 8,
"hurt": 4,
"celebrate": 6,
}
ACTION_OFFSETS = {
"attack": 0,
"strategy": 10,
"item": 18,
"hurt": 26,
"celebrate": 30,
}
ACTION_FRAMES_PER_DIRECTION = sum(ACTION_COUNTS.values())
def remove_magenta(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
arr = np.array(rgba)
rgb = arr[:, :, :3].astype(np.int16)
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
key = (r > 200) & (b > 200) & (g < 115)
near_key = (r > 145) & (b > 135) & (g < 140) & ((r + b - g * 2) > 145)
alpha = np.where(key | near_key, 0, 255).astype(np.uint8)
fringe = (alpha > 0) & (r > 150) & (b > 150) & (g < 140)
arr[:, :, 0] = np.where(fringe, np.minimum(arr[:, :, 0], 90), arr[:, :, 0])
arr[:, :, 2] = np.where(fringe, np.minimum(arr[:, :, 2], 90), arr[:, :, 2])
arr[:, :, 3] = alpha
return Image.fromarray(arr, "RGBA")
def keep_useful_components(image: Image.Image, allow_detached_effects = False) -> Image.Image:
arr = np.array(image.convert("RGBA"))
alpha = arr[:, :, 3] > 0
height, width = alpha.shape
visited = np.zeros_like(alpha, dtype=bool)
components: list[tuple[int, int, int, int, int, float, float]] = []
for start_y in range(height):
for start_x in range(width):
if visited[start_y, start_x] or not alpha[start_y, start_x]:
continue
stack = [(start_x, start_y)]
visited[start_y, start_x] = True
xs: list[int] = []
ys: list[int] = []
while stack:
x, y = stack.pop()
xs.append(x)
ys.append(y)
for ny in (y - 1, y, y + 1):
if ny < 0 or ny >= height:
continue
for nx in (x - 1, x, x + 1):
if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]:
continue
visited[ny, nx] = True
stack.append((nx, ny))
area = len(xs)
if area < 24:
continue
left, right = min(xs), max(xs)
top, bottom = min(ys), max(ys)
components.append((area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2))
if not components:
return image
anchor = max(components, key=lambda item: item[0])
anchor_area, left, top, right, bottom, anchor_cx, anchor_cy = anchor
expanded = (left - 95, top - 90, right + 95, bottom + 82)
keep = np.zeros_like(alpha, dtype=bool)
for component in components:
area, c_left, c_top, c_right, c_bottom, cx, cy = component
near_anchor = c_right >= expanded[0] and c_left <= expanded[2] and c_bottom >= expanded[1] and c_top <= expanded[3]
big_effect = area >= max(550, anchor_area * 0.07)
useful = component == anchor or (near_anchor and area >= 55) or (allow_detached_effects and big_effect)
if useful:
keep[c_top : c_bottom + 1, c_left : c_right + 1] |= alpha[c_top : c_bottom + 1, c_left : c_right + 1]
arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def keep_largest_component(image: Image.Image) -> Image.Image:
arr = np.array(image.convert("RGBA"))
alpha = arr[:, :, 3] > 0
height, width = alpha.shape
visited = np.zeros_like(alpha, dtype=bool)
best_mask: np.ndarray | None = None
best_area = 0
for start_y in range(height):
for start_x in range(width):
if visited[start_y, start_x] or not alpha[start_y, start_x]:
continue
stack = [(start_x, start_y)]
visited[start_y, start_x] = True
mask = np.zeros_like(alpha, dtype=bool)
area = 0
while stack:
x, y = stack.pop()
mask[y, x] = True
area += 1
for ny in (y - 1, y, y + 1):
if ny < 0 or ny >= height:
continue
for nx in (x - 1, x, x + 1):
if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]:
continue
visited[ny, nx] = True
stack.append((nx, ny))
if area > best_area:
best_area = area
best_mask = mask
if best_mask is None:
return image
arr[:, :, 3] = np.where(best_mask, arr[:, :, 3], 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def crop_cell(source: Image.Image, row: int, col: int) -> Image.Image:
width, height = source.size
left = round(col * width / 6)
right = round((col + 1) * width / 6)
top = round(row * height / 2)
bottom = round((row + 1) * height / 2)
pad_x = 0
pad_y = 6
return source.crop((max(0, left - pad_x), max(0, top - pad_y), min(width, right + pad_x), min(height, bottom + pad_y)))
def crop_subject(image: Image.Image, allow_detached_effects = False) -> Image.Image:
rgba = keep_useful_components(remove_magenta(image), allow_detached_effects)
return trim_to_alpha(rgba, pad=7)
def trim_to_alpha(image: Image.Image, pad: int = 0) -> Image.Image:
rgba = image.convert("RGBA")
alpha = np.array(rgba.getchannel("A"))
ys, xs = np.where(alpha > 0)
if len(xs) == 0:
return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
left = max(int(xs.min()) - pad, 0)
top = max(int(ys.min()) - pad, 0)
right = min(int(xs.max()) + pad + 1, rgba.width)
bottom = min(int(ys.max()) + pad + 1, rgba.height)
return solidify_alpha(rgba.crop((left, top, right, bottom)))
def solidify_alpha(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
arr = np.array(rgba)
arr[:, :, 3] = np.where(arr[:, :, 3] > 20, 255, 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def fit_frame(source: Image.Image, max_width: int, max_height: int, bottom: int = 304, x_offset: int = 0, allow_detached_effects = False) -> Image.Image:
subject = crop_subject(source, allow_detached_effects)
if subject.width <= 1 or subject.height <= 1:
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
scale = min(max_width / subject.width, max_height / subject.height)
size = (max(1, round(subject.width * scale)), max(1, round(subject.height * scale)))
subject = solidify_alpha(subject.resize(size, Image.Resampling.LANCZOS))
subject = trim_to_alpha(subject)
if not allow_detached_effects:
subject = keep_largest_component(subject)
subject = trim_to_alpha(subject)
frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
x = (FRAME - subject.width) // 2 + x_offset
y = bottom - subject.height
frame.alpha_composite(subject, (x, y))
return solidify_alpha(frame)
def mirror(frame: Image.Image) -> Image.Image:
return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
def load_pose_frames() -> dict[str, Image.Image]:
source = Image.open(SOURCE).convert("RGB")
cells = {(row, col): crop_cell(source, row, col) for row in range(2) for col in range(6)}
return {
"idle_front": fit_frame(cells[(0, 0)], 252, 300),
"walk_front": fit_frame(cells[(0, 1)], 252, 300),
"walk_side": fit_frame(cells[(0, 2)], 266, 300),
"walk_back": fit_frame(cells[(0, 3)], 254, 300),
"ready_side": fit_frame(cells[(0, 4)], 266, 300),
"attack_ready": fit_frame(cells[(0, 5)], 284, 294),
"attack_slash": fit_frame(cells[(1, 0)], 300, 294, allow_detached_effects=True),
"attack_thrust": fit_frame(cells[(1, 1)], 292, 294),
"command": keep_largest_component(fit_frame(cells[(1, 2)], 264, 300)),
"hurt": fit_frame(cells[(1, 3)], 252, 294),
"victory": keep_largest_component(fit_frame(cells[(1, 4)], 264, 300)),
"walk_side_alt": fit_frame(cells[(1, 5)], 266, 300),
}
def build_base_sheet(poses: dict[str, Image.Image]) -> Image.Image:
rows = {
"south": {
"idle": [poses["idle_front"]] * 8,
"walk": [poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"]],
},
"east": {
"idle": [poses["ready_side"]] * 8,
"walk": [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]],
},
"north": {
"idle": [poses["walk_back"]] * 8,
"walk": [poses["walk_back"]] * 8,
},
"west": {
"idle": [mirror(poses["ready_side"])] * 8,
"walk": [mirror(frame) for frame in [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]]],
},
}
sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
for row, direction in enumerate(DIRECTIONS):
frames = rows[direction]["idle"] + rows[direction]["walk"]
for col, frame in enumerate(frames):
sheet.alpha_composite(solidify_alpha(frame), (col * FRAME, row * FRAME))
return solidify_alpha(sheet)
def directed(frame: Image.Image, direction: str) -> Image.Image:
if direction == "west":
return mirror(frame)
return frame
def build_action_sheet(poses: dict[str, Image.Image]) -> Image.Image:
attack = [
poses["attack_ready"],
poses["attack_ready"],
poses["attack_thrust"],
poses["attack_slash"],
poses["attack_slash"],
poses["attack_thrust"],
poses["ready_side"],
poses["walk_side_alt"],
poses["ready_side"],
poses["ready_side"],
]
strategy = [poses["command"], poses["command"], poses["victory"], poses["command"], poses["command"], poses["victory"], poses["command"], poses["idle_front"]]
item = [poses["idle_front"], poses["command"], poses["victory"], poses["command"], poses["idle_front"], poses["command"], poses["victory"], poses["idle_front"]]
hurt = [poses["hurt"]] * 4
celebrate = [poses["victory"], poses["command"], poses["victory"], poses["command"], poses["victory"], poses["idle_front"]]
groups = {
"attack": attack,
"strategy": strategy,
"item": item,
"hurt": hurt,
"celebrate": celebrate,
}
sheet = Image.new("RGBA", (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
for row, direction in enumerate(DIRECTIONS):
for action, frames in groups.items():
offset = ACTION_OFFSETS[action]
for index, frame in enumerate(frames):
action_frame = directed(frame, direction)
if direction == "south" and action == "attack":
action_frame = frame
if direction == "north" and action in {"attack", "strategy", "item", "celebrate"}:
action_frame = poses["walk_back"] if action == "attack" else poses["command"]
sheet.alpha_composite(solidify_alpha(action_frame), ((offset + index) * FRAME, row * FRAME))
return solidify_alpha(sheet)
def checker_background(size: tuple[int, int], tile: int = 16) -> Image.Image:
image = Image.new("RGBA", size, (64, 84, 53, 255))
draw = ImageDraw.Draw(image)
for y in range(0, size[1], tile):
for x in range(0, size[0], tile):
color = (75, 97, 60, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (55, 72, 47, 255)
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color)
return image
def font(size: int = 12):
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
try:
return ImageFont.truetype(candidate, size)
except OSError:
continue
return ImageFont.load_default()
def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image:
bg = checker_background((size, size), 8)
scaled = solidify_alpha(frame).resize((size, size), Image.Resampling.LANCZOS)
bg.alpha_composite(scaled, (0, 0))
return bg
def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image:
return sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME))
def save_contact_sheet(base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
picks = [
("idle", crop_frame(base_sheet, 0, 0)),
("walk S", crop_frame(base_sheet, 0, 8)),
("walk E", crop_frame(base_sheet, 1, 8)),
("walk N", crop_frame(base_sheet, 2, 8)),
("walk W", crop_frame(base_sheet, 3, 8)),
("atk 1", crop_frame(action_sheet, 1, 0)),
("atk 4", crop_frame(action_sheet, 1, 3)),
("atk 7", crop_frame(action_sheet, 1, 6)),
("cmd", crop_frame(action_sheet, 0, ACTION_OFFSETS["strategy"] + 2)),
("item", crop_frame(action_sheet, 0, ACTION_OFFSETS["item"] + 1)),
("hurt", crop_frame(action_sheet, 0, ACTION_OFFSETS["hurt"])),
("win", crop_frame(action_sheet, 0, ACTION_OFFSETS["celebrate"] + 1)),
]
thumb = 132
pad = 18
label_h = 24
cols = 6
rows = 2
out = Image.new("RGBA", (pad + cols * (thumb + pad), pad + rows * (thumb + label_h + pad) + 18), (20, 24, 22, 255))
draw = ImageDraw.Draw(out)
draw.text((pad, 5), CONTACT_TITLE, fill=(240, 226, 178, 255), font=font(14))
for index, (label, frame) in enumerate(picks):
x = pad + (index % cols) * (thumb + pad)
y = 24 + pad + (index // cols) * (thumb + label_h + pad)
out.alpha_composite(draw_frame_on_bg(frame, thumb), (x, y))
draw.text((x, y + thumb + 5), label, fill=(238, 224, 178, 255), font=font(11))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-contact.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_before_after(base_sheet: Image.Image) -> Path:
before = Image.open(WORK_DIR / f"before-{STEM}.png").convert("RGBA").crop((0, 0, FRAME, FRAME))
after = crop_frame(base_sheet, 0, 0)
out = Image.new("RGBA", (820, 360), (18, 21, 20, 255))
draw = ImageDraw.Draw(out)
draw.text((30, 18), BEFORE_TITLE, fill=(238, 224, 178, 255), font=font(14))
out.alpha_composite(draw_frame_on_bg(before, 160), (30, 48))
out.alpha_composite(draw_frame_on_bg(after, 160), (220, 48))
draw.text((30, 218), BEFORE_SCALE_LABEL, fill=(238, 224, 178, 255), font=font(12))
out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252))
out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-before-after.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_animation_gif(base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
frames: list[Image.Image] = []
for index in range(10):
canvas = checker_background((540, 190), 10)
idle = crop_frame(base_sheet, 0, index % 8)
walk = crop_frame(base_sheet, 1, 8 + index % 8)
attack = crop_frame(action_sheet, 1, index)
for x, frame in ((22, idle), (190, walk), (358, attack)):
canvas.alpha_composite(frame.resize((150, 150), Image.Resampling.LANCZOS), (x, 25))
frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=160))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-animation.gif"
frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=120, loop=0)
return path
def save_scale_compare() -> Path:
stems = COMPARE_STEMS
labels = COMPARE_LABELS
tile = 50
zoom = 72
gap = 22
width = 28 + len(stems) * (zoom + gap)
height = 210
out = Image.new("RGBA", (width, height), (19, 25, 21, 255))
draw = ImageDraw.Draw(out)
draw.text((18, 10), "Battle-scale readability compare", fill=(240, 226, 178, 255), font=font(14))
for index, stem in enumerate(stems):
sheet = Image.open(UNIT_DIR / f"{stem}.png").convert("RGBA")
frame = crop_frame(sheet, 0, 0)
x = 18 + index * (zoom + gap)
out.alpha_composite(draw_frame_on_bg(frame, zoom), (x, 38))
out.alpha_composite(draw_frame_on_bg(frame, tile), (x + 11, 122))
draw.text((x, 180), labels[index], fill=(226, 218, 175, 255), font=font(10))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-scale-compare.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_battle_composite(base_sheet: Image.Image) -> Path:
battle = Image.open(BATTLE_REFERENCE).convert("RGBA")
frame = crop_frame(base_sheet, 0, 0)
draw = ImageDraw.Draw(battle)
display = 72
small = 50
# Empty-ish field cells near the approved ally sprites. This is a review
# composite only; the gameplay roster is left untouched during sample review.
battle.alpha_composite(solidify_alpha(frame).resize((display, display), Image.Resampling.LANCZOS), (207, 535))
draw.text((207, 612), SAMPLE_LABEL, fill=(255, 248, 218, 255), font=font(12), stroke_width=2, stroke_fill=(20, 18, 16, 230))
panel_x, panel_y = 936, 542
draw.rounded_rectangle((panel_x, panel_y, panel_x + 205, panel_y + 116), radius=4, fill=(17, 23, 31, 235), outline=(214, 181, 99, 220), width=2)
draw.text((panel_x + 14, panel_y + 12), "Composite scale check", fill=(240, 226, 178, 255), font=font(13))
draw.text((panel_x + 14, panel_y + 32), "not gameplay", fill=(198, 204, 200, 255), font=font(11))
battle.alpha_composite(draw_frame_on_bg(frame, display), (panel_x + 18, panel_y + 56))
battle.alpha_composite(draw_frame_on_bg(frame, small), (panel_x + 114, panel_y + 67))
draw.text((panel_x + 20, panel_y + 132), "72px", fill=(238, 224, 178, 255), font=font(10))
draw.text((panel_x + 117, panel_y + 132), "50px", fill=(238, 224, 178, 255), font=font(10))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-battle-composite.png"
battle.convert("RGB").save(path, optimize=True)
return path
def validate(path: Path, expected: tuple[int, int], rows: int, cols: int) -> dict[str, int | str]:
image = Image.open(path).convert("RGBA")
if image.size != expected:
raise ValueError(f"{path}: expected {expected}, got {image.size}")
alpha = np.array(image.getchannel("A"))
partial = int(np.count_nonzero((alpha > 0) & (alpha < 255)))
opaque = int(np.count_nonzero(alpha == 255))
if partial:
raise ValueError(f"{path.name}: partial alpha {partial}")
widths: list[int] = []
heights: list[int] = []
bottoms: list[int] = []
border_hits = 0
empty_frames = 0
for row in range(rows):
for col in range(cols):
frame = alpha[row * FRAME : (row + 1) * FRAME, col * FRAME : (col + 1) * FRAME]
ys, xs = np.where(frame > 0)
if len(xs) == 0:
empty_frames += 1
continue
widths.append(int(xs.max() - xs.min() + 1))
heights.append(int(ys.max() - ys.min() + 1))
bottoms.append(int(ys.max()))
if xs.min() <= 1 or ys.min() <= 1 or xs.max() >= FRAME - 2 or ys.max() >= FRAME - 2:
border_hits += 1
return {
"partial_alpha": partial,
"opaque_pixels": opaque,
"border_hits": border_hits,
"empty_frames": empty_frames,
"min_width": min(widths) if widths else 0,
"max_width": max(widths) if widths else 0,
"min_height": min(heights) if heights else 0,
"max_height": max(heights) if heights else 0,
"min_bottom": min(bottoms) if bottoms else 0,
"max_bottom": max(bottoms) if bottoms else 0,
"status": "ok" if border_hits == 0 and empty_frames == 0 else "needs-review",
}
def copy_before_once() -> None:
WORK_DIR.mkdir(parents=True, exist_ok=True)
for suffix in ("", "-actions"):
source = UNIT_DIR / f"{STEM}{suffix}.png"
target = WORK_DIR / f"before-{STEM}{suffix}.png"
if not target.exists():
target.write_bytes(source.read_bytes())
def project_path(value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else ROOT / path
def split_csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Assemble a 6x2 hand-painted source into 313px unit sprite sheets.")
parser.add_argument("--source", default=str(SOURCE.relative_to(ROOT)))
parser.add_argument("--stem", default=STEM)
parser.add_argument("--work-dir", default=str(WORK_DIR.relative_to(ROOT)))
parser.add_argument("--output-prefix", default=OUTPUT_PREFIX)
parser.add_argument("--battle-reference", default=str(BATTLE_REFERENCE.relative_to(ROOT)))
parser.add_argument("--contact-title", default=CONTACT_TITLE)
parser.add_argument("--before-title", default=BEFORE_TITLE)
parser.add_argument("--before-scale-label", default=BEFORE_SCALE_LABEL)
parser.add_argument("--report-title", default=REPORT_TITLE)
parser.add_argument("--sample-label", default=SAMPLE_LABEL)
parser.add_argument("--compare-stems", default=",".join(COMPARE_STEMS))
parser.add_argument("--compare-labels", default=",".join(COMPARE_LABELS))
return parser.parse_args()
def configure(args: argparse.Namespace) -> None:
global SOURCE, WORK_DIR, BATTLE_REFERENCE, STEM, OUTPUT_PREFIX
global CONTACT_TITLE, BEFORE_TITLE, BEFORE_SCALE_LABEL, REPORT_TITLE, SAMPLE_LABEL
global COMPARE_STEMS, COMPARE_LABELS
SOURCE = project_path(args.source)
WORK_DIR = project_path(args.work_dir)
BATTLE_REFERENCE = project_path(args.battle_reference)
STEM = args.stem
OUTPUT_PREFIX = args.output_prefix
CONTACT_TITLE = args.contact_title
BEFORE_TITLE = args.before_title
BEFORE_SCALE_LABEL = args.before_scale_label
REPORT_TITLE = args.report_title
SAMPLE_LABEL = args.sample_label
COMPARE_STEMS = split_csv(args.compare_stems)
COMPARE_LABELS = split_csv(args.compare_labels)
if len(COMPARE_STEMS) != len(COMPARE_LABELS):
raise ValueError("--compare-stems and --compare-labels must have the same number of entries")
def main() -> None:
configure(parse_args())
copy_before_once()
poses = load_pose_frames()
base_sheet = build_base_sheet(poses)
action_sheet = build_action_sheet(poses)
base_path = UNIT_DIR / f"{STEM}.png"
action_path = UNIT_DIR / f"{STEM}-actions.png"
base_sheet.save(base_path, optimize=True)
action_sheet.save(action_path, optimize=True)
contact = save_contact_sheet(base_sheet, action_sheet)
before_after = save_before_after(base_sheet)
animation = save_animation_gif(base_sheet, action_sheet)
scale_compare = save_scale_compare()
battle_composite = save_battle_composite(base_sheet)
base_result = validate(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), BASE_FRAMES_PER_DIRECTION)
action_result = validate(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), ACTION_FRAMES_PER_DIRECTION)
report = DOCS_DIR / f"{OUTPUT_PREFIX}-report.md"
report.write_text(
"\n".join(
[
f"# {REPORT_TITLE}",
"",
f"- source: `{SOURCE.name}`",
f"- base status: {base_result['status']}",
f"- action status: {action_result['status']}",
f"- base partial alpha: {base_result['partial_alpha']}, opaque pixels: {base_result['opaque_pixels']}",
f"- action partial alpha: {action_result['partial_alpha']}, opaque pixels: {action_result['opaque_pixels']}",
f"- base frame border hits: {base_result['border_hits']}",
f"- action frame border hits: {action_result['border_hits']}",
f"- base empty frames: {base_result['empty_frames']}",
f"- action empty frames: {action_result['empty_frames']}",
f"- base bbox width range: {base_result['min_width']}..{base_result['max_width']}",
f"- base bbox height range: {base_result['min_height']}..{base_result['max_height']}",
f"- base bottom range: {base_result['min_bottom']}..{base_result['max_bottom']}",
f"- action bbox width range: {action_result['min_width']}..{action_result['max_width']}",
f"- action bbox height range: {action_result['min_height']}..{action_result['max_height']}",
f"- action bottom range: {action_result['min_bottom']}..{action_result['max_bottom']}",
f"- contact: `{contact.name}`",
f"- animation: `{animation.name}`",
f"- before/after: `{before_after.name}`",
f"- scale compare: `{scale_compare.name}`",
f"- battle composite: `{battle_composite.name}`",
"- manual checks: run build and local browser verification after generation",
"- note: battle composite is a scale review image; gameplay roster is unchanged during sample approval",
"",
]
),
encoding="utf-8",
)
print(f"Wrote {base_path}")
print(f"Wrote {action_path}")
print(f"Wrote {contact}")
print(f"Wrote {animation}")
print(f"Wrote {before_after}")
print(f"Wrote {scale_compare}")
print(f"Wrote {battle_composite}")
print(f"Wrote {report}")
if __name__ == "__main__":
main()