666 lines
29 KiB
Python
666 lines
29 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"]
|
|
FIT_WIDTH_SCALE = 1.0
|
|
MIN_FRAME_HEIGHT = 0
|
|
STRETCH_X = 1.0
|
|
ACTION_SHRINK_X = 1.0
|
|
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 grow_to_minimum_height(subject: Image.Image, max_width: int, max_height: int) -> Image.Image:
|
|
if MIN_FRAME_HEIGHT <= 0 or subject.height >= MIN_FRAME_HEIGHT:
|
|
return subject
|
|
grow = min(max_width / subject.width, max_height / subject.height, MIN_FRAME_HEIGHT / subject.height)
|
|
if grow <= 1:
|
|
return subject
|
|
size = (max(1, round(subject.width * grow)), max(1, round(subject.height * grow)))
|
|
return trim_to_alpha(solidify_alpha(subject.resize(size, Image.Resampling.LANCZOS)))
|
|
|
|
|
|
def stretch_subject_x(subject: Image.Image, max_width: int) -> Image.Image:
|
|
if STRETCH_X <= 1:
|
|
return subject
|
|
stretched_width = min(max_width, max(1, round(subject.width * STRETCH_X)))
|
|
if stretched_width <= subject.width:
|
|
return subject
|
|
return trim_to_alpha(solidify_alpha(subject.resize((stretched_width, subject.height), Image.Resampling.LANCZOS)))
|
|
|
|
|
|
def shrink_action_frame_x(frame: Image.Image) -> Image.Image:
|
|
if ACTION_SHRINK_X >= 1:
|
|
return frame
|
|
rgba = solidify_alpha(frame)
|
|
alpha = np.array(rgba.getchannel("A"))
|
|
ys, xs = np.where(alpha > 0)
|
|
if len(xs) == 0:
|
|
return rgba
|
|
left, right = int(xs.min()), int(xs.max()) + 1
|
|
top, bottom = int(ys.min()), int(ys.max()) + 1
|
|
subject = rgba.crop((left, top, right, bottom))
|
|
target_width = max(1, round(subject.width * ACTION_SHRINK_X))
|
|
if target_width >= subject.width:
|
|
return rgba
|
|
subject = solidify_alpha(subject.resize((target_width, subject.height), Image.Resampling.LANCZOS))
|
|
out = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
|
out.alpha_composite(subject, ((FRAME - target_width) // 2, bottom - subject.height))
|
|
return solidify_alpha(out)
|
|
|
|
|
|
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:
|
|
max_width = int(round(max_width * FIT_WIDTH_SCALE))
|
|
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)
|
|
subject = grow_to_minimum_height(subject, max_width, max_height)
|
|
if not allow_detached_effects:
|
|
subject = keep_largest_component(subject)
|
|
subject = trim_to_alpha(subject)
|
|
subject = grow_to_minimum_height(subject, max_width, max_height)
|
|
subject = stretch_subject_x(subject, max_width)
|
|
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"]
|
|
action_frame = shrink_action_frame_x(action_frame)
|
|
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))
|
|
parser.add_argument("--fit-width-scale", type=float, default=FIT_WIDTH_SCALE)
|
|
parser.add_argument("--min-frame-height", type=int, default=MIN_FRAME_HEIGHT)
|
|
parser.add_argument("--stretch-x", type=float, default=STRETCH_X)
|
|
parser.add_argument("--action-shrink-x", type=float, default=ACTION_SHRINK_X)
|
|
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
|
|
global FIT_WIDTH_SCALE, MIN_FRAME_HEIGHT, STRETCH_X, ACTION_SHRINK_X
|
|
|
|
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)
|
|
FIT_WIDTH_SCALE = args.fit_width_scale
|
|
MIN_FRAME_HEIGHT = args.min_frame_height
|
|
STRETCH_X = args.stretch_x
|
|
ACTION_SHRINK_X = args.action_shrink_x
|
|
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()
|