Improve handpainted battle unit readability

This commit is contained in:
2026-06-30 22:18:34 +09:00
parent 6eb3f029c3
commit 3092b295e6
101 changed files with 1405 additions and 16 deletions

View File

@@ -0,0 +1,906 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
WORK_DIR = ROOT / "tmp" / "batch1-handpaint-sprites"
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
DOCS_DIR = ROOT / "docs"
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())
@dataclass(frozen=True)
class UnitSpec:
key: str
label: str
max_width: int = 292
max_height: int = 300
bottom: int = 306
stretch_x: float = 1.12
UNIT_SPECS = [
UnitSpec("unit-guan-yu", "Guan Yu", max_width=306, max_height=304, stretch_x=1.18),
UnitSpec("unit-zhang-fei", "Zhang Fei", max_width=306, max_height=302, stretch_x=1.15),
UnitSpec("unit-rebel", "Yellow Turban Infantry", max_width=286, max_height=298, stretch_x=1.10),
UnitSpec("unit-rebel-archer", "Yellow Turban Archer", max_width=306, max_height=298, stretch_x=1.08),
UnitSpec("unit-rebel-cavalry", "Yellow Turban Cavalry", max_width=306, max_height=292, stretch_x=1.02),
]
MANUAL_ACTION_CROPS: dict[str, dict[str, list[tuple[int, int, int, int]]]] = {
"unit-guan-yu": {
"attack": [
(78, 154, 210, 286),
(240, 154, 248, 286),
(406, 154, 260, 286),
(566, 154, 252, 286),
(724, 154, 238, 286),
(886, 154, 252, 286),
(1046, 154, 252, 286),
(1220, 154, 310, 286),
(1408, 154, 246, 286),
(1584, 154, 218, 286),
],
"middle": [
(250, 500, 330, 340),
(555, 500, 310, 340),
(825, 500, 310, 340),
(1110, 500, 330, 340),
(1420, 500, 330, 340),
],
"lower": [
(530, 762, 360, 330),
(835, 762, 340, 330),
(1160, 762, 380, 330),
],
},
"unit-zhang-fei": {
"attack": [
(82, 154, 222, 286),
(250, 154, 238, 286),
(420, 154, 262, 286),
(590, 154, 262, 286),
(756, 154, 266, 286),
(925, 154, 270, 286),
(1096, 154, 260, 286),
(1268, 154, 270, 286),
(1432, 154, 240, 286),
(1586, 154, 218, 286),
],
"middle": [
(260, 502, 360, 340),
(555, 502, 330, 340),
(825, 502, 330, 340),
(1090, 502, 350, 340),
(1402, 502, 360, 340),
],
"lower": [
(520, 764, 380, 330),
(835, 764, 360, 330),
(1160, 764, 390, 330),
],
},
"unit-rebel": {
"attack": [
(82, 154, 214, 286),
(260, 154, 218, 286),
(454, 154, 236, 286),
(635, 154, 238, 286),
(810, 154, 236, 286),
(990, 154, 250, 286),
(1195, 154, 236, 286),
(1375, 154, 230, 286),
(1518, 154, 224, 286),
(1618, 154, 210, 286),
],
"middle": [
(315, 510, 350, 340),
(580, 510, 340, 340),
(825, 510, 320, 340),
(1082, 510, 330, 340),
(1320, 510, 330, 340),
],
"lower": [
(650, 765, 360, 330),
(852, 765, 330, 330),
(1088, 765, 350, 330),
],
},
"unit-rebel-archer": {
"attack": [
(70, 154, 210, 286),
(235, 154, 214, 286),
(405, 154, 230, 286),
(578, 154, 242, 286),
(748, 154, 250, 286),
(912, 154, 250, 286),
(1080, 154, 260, 286),
(1250, 154, 260, 286),
(1420, 154, 232, 286),
(1586, 154, 216, 286),
],
"middle": [
(275, 510, 330, 340),
(575, 510, 330, 340),
(835, 510, 330, 340),
(1095, 510, 330, 340),
(1370, 510, 350, 340),
],
"lower": [
(565, 765, 370, 330),
(835, 765, 340, 330),
(1085, 765, 370, 330),
],
},
"unit-rebel-cavalry": {
"attack": [
(82, 154, 280, 286),
(255, 154, 286, 286),
(430, 154, 300, 286),
(605, 154, 300, 286),
(780, 154, 300, 286),
(960, 154, 320, 286),
(1155, 154, 330, 286),
(1355, 154, 310, 286),
(1510, 154, 282, 286),
(1610, 154, 230, 286),
],
"middle": [
(350, 510, 410, 340),
(610, 510, 390, 340),
(845, 510, 390, 340),
(1095, 510, 390, 340),
(1335, 510, 420, 340),
],
"lower": [
(560, 765, 440, 330),
(855, 765, 390, 330),
(1115, 765, 420, 330),
],
},
}
ACTION_CROPS_V2: dict[str, dict[str, list[tuple[int, int, int, int]]]] = {
"unit-guan-yu": {
"attack": [
(92, 150, 220, 280),
(280, 150, 245, 280),
(500, 150, 255, 280),
(700, 150, 255, 280),
(875, 150, 245, 280),
(1045, 150, 255, 280),
(1250, 150, 315, 280),
(1430, 150, 245, 280),
(1590, 150, 220, 280),
(1698, 150, 180, 280),
],
"middle": [
(255, 475, 330, 330),
(555, 475, 310, 330),
(825, 475, 310, 330),
(1130, 475, 350, 330),
(1465, 475, 330, 330),
],
"lower": [
(540, 735, 380, 320),
(835, 735, 360, 320),
(1165, 735, 400, 320),
],
},
"unit-zhang-fei": {
"attack": [
(88, 150, 230, 280),
(272, 150, 250, 280),
(486, 150, 265, 280),
(690, 150, 275, 280),
(880, 150, 260, 280),
(1062, 150, 255, 280),
(1230, 150, 245, 280),
(1398, 150, 245, 280),
(1570, 150, 245, 280),
(1695, 150, 185, 280),
],
"middle": [
(250, 475, 350, 330),
(565, 475, 330, 330),
(850, 475, 330, 330),
(1135, 475, 360, 330),
(1460, 475, 360, 330),
],
"lower": [
(525, 735, 400, 320),
(850, 735, 370, 320),
(1190, 735, 410, 320),
],
},
"unit-rebel": {
"attack": [
(80, 155, 215, 285),
(245, 155, 220, 285),
(410, 155, 225, 285),
(590, 155, 225, 285),
(755, 155, 230, 285),
(940, 155, 265, 285),
(1120, 155, 230, 285),
(1285, 155, 225, 285),
(1450, 155, 225, 285),
(1595, 155, 205, 285),
],
"middle": [
(275, 510, 350, 340),
(550, 510, 340, 340),
(835, 510, 330, 340),
(1085, 510, 335, 340),
(1345, 510, 350, 340),
],
"lower": [
(590, 765, 370, 330),
(835, 765, 340, 330),
(1085, 765, 370, 330),
],
},
"unit-rebel-archer": {
"attack": [
(88, 150, 220, 280),
(260, 150, 220, 280),
(455, 150, 245, 280),
(650, 150, 250, 280),
(850, 150, 260, 280),
(1040, 150, 260, 280),
(1255, 150, 260, 280),
(1435, 150, 245, 280),
(1600, 150, 230, 280),
(1600, 150, 230, 280),
],
"middle": [
(270, 475, 340, 330),
(570, 475, 330, 330),
(850, 475, 330, 330),
(1130, 475, 340, 330),
(1445, 475, 360, 330),
],
"lower": [
(555, 735, 380, 320),
(835, 735, 350, 320),
(1120, 735, 390, 320),
],
},
"unit-rebel-cavalry": {
"attack": [
(80, 154, 300, 286),
(260, 154, 300, 286),
(445, 154, 315, 286),
(620, 154, 315, 286),
(800, 154, 330, 286),
(980, 154, 330, 286),
(1145, 154, 320, 286),
(1300, 154, 300, 286),
(1460, 154, 285, 286),
(1600, 154, 260, 286),
],
"middle": [
(340, 510, 420, 340),
(600, 510, 400, 340),
(850, 510, 400, 340),
(1095, 510, 400, 340),
(1340, 510, 420, 340),
],
"lower": [
(590, 765, 460, 330),
(850, 765, 400, 330),
(1120, 765, 430, 330),
],
},
}
def keyed_rgba(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 > 185) & (b > 185) & (g < 125)
near_key = (r > 145) & (b > 135) & (g < 150) & ((r + b - g * 2) > 180)
alpha = np.where(key | near_key, 0, 255).astype(np.uint8)
body = alpha > 0
magenta_fringe = body & (r > 155) & (b > 135) & (g < 130)
arr[:, :, 0] = np.where(magenta_fringe, np.minimum(arr[:, :, 0], 72), arr[:, :, 0])
arr[:, :, 2] = np.where(magenta_fringe, np.minimum(arr[:, :, 2], 72), arr[:, :, 2])
arr[:, :, 3] = alpha
return Image.fromarray(arr, "RGBA")
def connected_components(alpha: np.ndarray) -> list[tuple[int, int, int, int, int, float, float]]:
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 < 18:
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))
return components
def connected_component_masks(
alpha: np.ndarray,
) -> list[tuple[tuple[int, int, int, int, int, float, float], np.ndarray]]:
height, width = alpha.shape
visited = np.zeros_like(alpha, dtype=bool)
components: list[tuple[tuple[int, int, int, int, int, float, float], np.ndarray]] = []
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] = []
mask = np.zeros_like(alpha, dtype=bool)
while stack:
x, y = stack.pop()
xs.append(x)
ys.append(y)
mask[y, x] = True
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 < 18:
continue
left, right = min(xs), max(xs)
top, bottom = min(ys), max(ys)
component = (area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2)
components.append((component, mask))
return components
def keep_subject_components(image: Image.Image) -> Image.Image:
arr = np.array(image.convert("RGBA"))
alpha = arr[:, :, 3] > 0
height, width = alpha.shape
components = connected_components(alpha)
if not components:
return image
image_center_x = width / 2
image_center_y = height * 0.60
anchor = max(
components,
key=lambda component: component[0]
- abs(component[5] - image_center_x) * 2.6
- abs(component[6] - image_center_y) * 1.2,
)
anchor_area, left, top, right, bottom, anchor_cx, _ = anchor
expanded = (left - 92, top - 92, right + 92, bottom + 76)
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]
)
central = 0.015 * width <= cx <= 0.985 * width
substantial = area >= max(22, anchor_area * 0.006)
edge_fragment = (
component != anchor
and (c_left <= 1 or c_right >= width - 2 or c_top <= 1 or c_bottom >= height - 2)
and area < anchor_area * 0.45
)
far_fragment = component != anchor and abs(cx - anchor_cx) > width * 0.30 and area < anchor_area * 0.75
if component == anchor or (near_anchor and central and substantial and not edge_fragment and not far_fragment):
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 crop_subject(image: Image.Image) -> Image.Image:
rgba = keep_subject_components(keyed_rgba(image))
alpha = np.array(rgba.getchannel("A"))
ys, xs = np.where(alpha > 0)
if len(xs) == 0:
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
pad = 9
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 rgba.crop((left, top, right, bottom))
def quantize_alpha(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
arr = np.array(rgba)
arr[:, :, 3] = np.where(arr[:, :, 3] > 24, 255, 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def fit_subject(source: Image.Image, spec: UnitSpec, scale_bias: float = 1.0) -> Image.Image:
subject = crop_subject(source)
if subject.width <= 1 or subject.height <= 1:
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
max_width = min(FRAME - 4, round(spec.max_width * scale_bias))
max_height = min(FRAME - 4, round(spec.max_height * scale_bias))
scale = min(max_width / subject.width, max_height / subject.height)
size = (max(1, round(subject.width * scale)), max(1, round(subject.height * scale)))
subject = subject.resize(size, Image.Resampling.LANCZOS)
stretched_width = min(max_width, max(1, round(subject.width * spec.stretch_x)))
if stretched_width != subject.width:
subject = subject.resize((stretched_width, subject.height), Image.Resampling.LANCZOS)
subject = quantize_alpha(subject)
frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
x = (FRAME - subject.width) // 2
y = spec.bottom - subject.height
frame.alpha_composite(subject, (x, y))
return quantize_alpha(keep_subject_components(frame))
def compact_frame_width(frame: Image.Image, max_content_width: int) -> Image.Image:
rgba = frame.convert("RGBA")
alpha = np.array(rgba.getchannel("A"))
ys, xs = np.where(alpha > 0)
if len(xs) == 0:
return rgba
left = int(xs.min())
right = int(xs.max()) + 1
top = int(ys.min())
bottom = int(ys.max()) + 1
content = rgba.crop((left, top, right, bottom))
if content.width <= max_content_width:
return rgba
content = content.resize((max_content_width, content.height), Image.Resampling.LANCZOS)
content = quantize_alpha(content)
out = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
out.alpha_composite(content, ((FRAME - max_content_width) // 2, bottom - content.height))
return quantize_alpha(keep_subject_components(out))
def compact_walk_frame(frame: Image.Image, spec: UnitSpec) -> Image.Image:
if spec.key == "unit-guan-yu":
return compact_frame_width(frame, 230)
if spec.key == "unit-zhang-fei":
return compact_frame_width(frame, 220)
return frame
def crop_grid(image: Image.Image, row: int, col: int, rows: int, cols: int, x_pad: int = 0, y_pad: int = 0) -> Image.Image:
cell_w = image.width / cols
cell_h = image.height / rows
left = max(0, int(round(col * cell_w)) - x_pad)
top = max(0, int(round(row * cell_h)) - y_pad)
right = min(image.width, int(round((col + 1) * cell_w)) + x_pad)
bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad)
return image.crop((left, top, right, bottom))
def crop_row(image: Image.Image, row: int, rows: int, y_pad: int = 0) -> Image.Image:
cell_h = image.height / rows
top = max(0, int(round(row * cell_h)) - y_pad)
bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad)
return image.crop((0, top, image.width, bottom))
def crop_center_xy(image: Image.Image, center_x: int, center_y: int, width: int, height: int) -> Image.Image:
left = max(0, center_x - width // 2)
top = max(0, center_y - height // 2)
right = min(image.width, center_x + width // 2)
bottom = min(image.height, center_y + height // 2)
return image.crop((left, top, right, bottom))
def tightened_crop(crop: tuple[int, int, int, int], spec: UnitSpec, group: str) -> tuple[int, int, int, int]:
center_x, center_y, width, height = crop
if group == "attack":
return center_x, center_y, width, height
return center_x, center_y, width, height
def action_fit_spec(spec: UnitSpec, group: str) -> UnitSpec:
if group != "attack":
return spec
attack_widths = {
"unit-guan-yu": 274,
"unit-zhang-fei": 276,
"unit-rebel-cavalry": 286,
}
max_width = attack_widths.get(spec.key)
if max_width is None:
return spec
return replace(spec, max_width=max_width)
def extract_row_subjects(image: Image.Image, row: int, rows: int, expected: int, y_pad: int = 0) -> list[Image.Image]:
row_image = keyed_rgba(crop_row(image, row, rows, y_pad))
arr = np.array(row_image.convert("RGBA"))
alpha = arr[:, :, 3] > 0
component_items = connected_component_masks(alpha)
if not component_items:
return [crop_grid(image, row, col, rows, expected) for col in range(expected)]
width = row_image.width
slot_width = width / expected
slot_centers = [(slot + 0.5) * slot_width for slot in range(expected)]
def body_score(component: tuple[int, int, int, int, int, float, float], slot_center: float) -> float:
area, left, top, right, bottom, cx, _ = component
component_width = right - left + 1
component_height = bottom - top + 1
aspect = component_width / max(1, component_height)
broadness = min(1.25, max(0.18, aspect))
slender_penalty = 0.25 if component_width < 18 or aspect < 0.16 else 1.0
edge_penalty = 0.55 if left <= 1 or right >= width - 2 else 1.0
return area * broadness * slender_penalty * edge_penalty - abs(cx - slot_center) * 42
anchors: list[int] = []
used: set[int] = set()
indexed_components = list(enumerate(component_items))
for slot_center in slot_centers:
candidates = [
(index, item)
for index, item in indexed_components
if index not in used and item[0][0] >= 90
]
if not candidates:
anchors.append(max(range(len(component_items)), key=lambda index: body_score(component_items[index][0], slot_center)))
continue
index, _ = max(candidates, key=lambda item: body_score(item[1][0], slot_center))
used.add(index)
anchors.append(index)
masks = [np.zeros_like(alpha, dtype=bool) for _ in range(expected)]
for component, component_mask in component_items:
area, left, top, right, bottom, cx, _ = component
if area < 28:
continue
slot = min(range(expected), key=lambda index: abs(cx - component_items[anchors[index]][0][5]))
anchor_area, anchor_left, anchor_top, anchor_right, anchor_bottom, anchor_cx, _ = component_items[anchors[slot]][0]
expanded = (
anchor_left - slot_width * 0.78,
anchor_top - 96,
anchor_right + slot_width * 0.78,
anchor_bottom + 96,
)
near_anchor = right >= expanded[0] and left <= expanded[2] and bottom >= expanded[1] and top <= expanded[3]
likely_neighbor_fragment = (
component != component_items[anchors[slot]][0]
and (left <= 2 or right >= width - 3)
and area < max(700, anchor_area * 0.18)
)
if not near_anchor or likely_neighbor_fragment:
continue
masks[slot] |= component_mask
subjects: list[Image.Image] = []
for slot, mask in enumerate(masks):
ys, xs = np.where(mask)
if len(xs) == 0:
subjects.append(crop_grid(image, row, slot, rows, expected))
continue
pad = 10
left = max(int(xs.min()) - pad, 0)
top = max(int(ys.min()) - pad, 0)
right = min(int(xs.max()) + pad + 1, row_image.width)
bottom = min(int(ys.max()) + pad + 1, row_image.height)
subject_arr = np.array(row_image.crop((left, top, right, bottom)).convert("RGBA"))
subject_mask = mask[top:bottom, left:right]
subject_arr[:, :, 3] = np.where(subject_mask, subject_arr[:, :, 3], 0).astype(np.uint8)
subjects.append(Image.fromarray(subject_arr, "RGBA"))
return subjects
def build_base_sheet(base_source: Image.Image, spec: UnitSpec) -> Image.Image:
rows: list[list[Image.Image]] = []
for row in range(4):
frames = [fit_subject(crop_grid(base_source, row, col, 4, 8, 0, 2), spec) for col in range(8)]
idle = [frames[0].copy() for _ in range(8)]
walk = [compact_walk_frame(frame, spec) for frame in frames]
rows.append(idle + walk)
sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
for row, frames in enumerate(rows):
for col, frame in enumerate(frames):
sheet.alpha_composite(frame, (col * FRAME, row * FRAME))
return sheet
def build_action_source_frames(action_source: Image.Image, spec: UnitSpec) -> dict[str, list[Image.Image]]:
layout = ACTION_CROPS_V2.get(spec.key)
if layout:
attack_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "attack")) for crop in layout["attack"]]
middle_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "middle")) for crop in layout["middle"]]
lower_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "lower")) for crop in layout["lower"]]
else:
attack_sources = [crop_grid(action_source, 0, col, 3, 10, 0, 18) for col in range(10)]
middle_sources = [crop_grid(action_source, 1, col, 3, 5, 0, 18) for col in range(5)]
lower_sources = [crop_grid(action_source, 2, col, 3, 3, 0, 12) for col in range(3)]
attack_frames = [fit_subject(source, action_fit_spec(spec, "attack")) for source in attack_sources]
middle = [fit_subject(source, spec) for source in middle_sources]
lower = [fit_subject(source, spec) for source in lower_sources]
return {
"attack": attack_frames,
"strategy": [middle[index % len(middle)] for index in (0, 1, 2, 1, 0, 1, 2, 1)],
"item": [middle[index % len(middle)] for index in (3, 4, 3, 4, 3, 4, 3, 4)],
"hurt": [lower[0] for _ in range(4)],
"celebrate": [lower[1], lower[2], lower[1], lower[2], lower[1], lower[2]],
}
def direction_frame(frame: Image.Image, direction: str) -> Image.Image:
if direction == "west":
return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
return frame
def build_action_sheet(action_source: Image.Image, spec: UnitSpec) -> Image.Image:
source_frames = build_action_source_frames(action_source, spec)
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 source_frames.items():
offset = ACTION_OFFSETS[action]
for index, frame in enumerate(frames):
sheet.alpha_composite(direction_frame(frame, direction), ((offset + index) * FRAME, row * FRAME))
return sheet
def representative_base_frames(base_sheet: Image.Image) -> list[Image.Image]:
picks = [(0, 0), (0, 10), (1, 10), (2, 10), (3, 10)]
return [base_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for row, col in picks]
def representative_action_frames(action_sheet: Image.Image) -> list[Image.Image]:
picks = [
(1, 0),
(1, 3),
(1, 6),
(0, ACTION_OFFSETS["strategy"] + 2),
(0, ACTION_OFFSETS["item"] + 1),
(0, ACTION_OFFSETS["hurt"]),
(0, ACTION_OFFSETS["celebrate"] + 1),
]
return [action_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for row, col in picks]
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 draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image:
bg = checker_background((size, size), 8)
scaled = frame.resize((size, size), Image.Resampling.LANCZOS)
bg.alpha_composite(scaled, (0, 0))
return bg
def save_contact_sheet(spec: UnitSpec, base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
thumbs = representative_base_frames(base_sheet) + representative_action_frames(action_sheet)
labels = ["idle", "walk S", "walk E", "walk N", "walk W", "atk 1", "atk 4", "atk 7", "cmd", "item", "hurt", "win"]
thumb_size = 132
pad = 18
label_h = 24
cols = 6
rows = 2
out = Image.new("RGBA", (pad + cols * (thumb_size + pad), pad + rows * (thumb_size + label_h + pad)), (20, 24, 22, 255))
draw = ImageDraw.Draw(out)
font = ImageFont.load_default()
draw.text((pad, 4), spec.label, fill=(240, 226, 178, 255), font=font)
for index, frame in enumerate(thumbs):
x = pad + (index % cols) * (thumb_size + pad)
y = 22 + pad + (index // cols) * (thumb_size + label_h + pad)
tile = draw_frame_on_bg(frame, thumb_size)
out.alpha_composite(tile, (x, y))
draw.text((x, y + thumb_size + 5), labels[index], fill=(238, 224, 178, 255), font=font)
path = DOCS_DIR / f"handpaint-batch1-{spec.key}-contact.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_before_after(spec: UnitSpec, base_sheet: Image.Image) -> Path:
before_path = WORK_DIR / f"before-{spec.key}.png"
before = Image.open(before_path).convert("RGBA").crop((0, 0, FRAME, FRAME))
after = base_sheet.crop((0, 0, FRAME, FRAME))
out = Image.new("RGBA", (820, 360), (18, 21, 20, 255))
draw = ImageDraw.Draw(out)
font = ImageFont.load_default()
draw.text((30, 18), f"{spec.label} Before / After at 313px", fill=(238, 224, 178, 255), font=font)
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 / After at battle scale", fill=(238, 224, 178, 255), font=font)
out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252))
out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252))
draw.text((30, 330), "left: previous asset, right: new hand-painted batch 1 asset", fill=(172, 185, 166, 255), font=font)
path = DOCS_DIR / f"handpaint-batch1-{spec.key}-before-after.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_animation_gif(spec: UnitSpec, 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 = base_sheet.crop(((index % 8) * FRAME, 0, ((index % 8) + 1) * FRAME, FRAME))
walk = base_sheet.crop(((8 + index % 8) * FRAME, FRAME, (9 + index % 8) * FRAME, 2 * FRAME))
attack = action_sheet.crop((index * FRAME, FRAME, (index + 1) * FRAME, 2 * FRAME))
for x, frame in ((22, idle), (190, walk), (358, attack)):
scaled = frame.resize((150, 150), Image.Resampling.LANCZOS)
canvas.alpha_composite(scaled, (x, 25))
frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=128))
path = DOCS_DIR / f"handpaint-batch1-{spec.key}-animation.gif"
frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=110, loop=0)
return path
def copy_before_once(spec: UnitSpec) -> None:
for suffix in ("", "-actions"):
source = UNIT_DIR / f"{spec.key}{suffix}.png"
target = WORK_DIR / f"before-{spec.key}{suffix}.png"
if not target.exists():
target.write_bytes(source.read_bytes())
def validate_sheet(path: Path, expected_size: tuple[int, int]) -> tuple[tuple[int, int], int, int]:
image = Image.open(path).convert("RGBA")
if image.size != expected_size:
raise ValueError(f"{path.name}: expected {expected_size}, 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}: found {partial} partially transparent pixels")
if opaque <= 0:
raise ValueError(f"{path.name}: no opaque subject pixels found")
return image.size, partial, opaque
def save_overview(processed: list[tuple[UnitSpec, Image.Image, Image.Image]]) -> Path:
thumb = 96
pad = 14
label_h = 20
cols = 5
rows = 3
out = Image.new("RGBA", (pad + cols * (thumb + pad), pad + rows * (thumb + label_h + pad)), (19, 23, 21, 255))
draw = ImageDraw.Draw(out)
font = ImageFont.load_default()
for col, (spec, base_sheet, action_sheet) in enumerate(processed):
frames = [
base_sheet.crop((0, 0, FRAME, FRAME)),
base_sheet.crop((10 * FRAME, FRAME, 11 * FRAME, 2 * FRAME)),
action_sheet.crop((3 * FRAME, FRAME, 4 * FRAME, 2 * FRAME)),
]
for row, frame in enumerate(frames):
x = pad + col * (thumb + pad)
y = pad + row * (thumb + label_h + pad)
out.alpha_composite(draw_frame_on_bg(frame, thumb), (x, y))
if row == 0:
draw.text((x, y + thumb + 4), spec.key.replace("unit-", ""), fill=(238, 224, 178, 255), font=font)
path = DOCS_DIR / "handpaint-batch1-overview-contact.png"
out.convert("RGB").save(path, optimize=True)
return path
def main() -> None:
WORK_DIR.mkdir(parents=True, exist_ok=True)
processed: list[tuple[UnitSpec, Image.Image, Image.Image]] = []
alpha_lines = ["# Handpaint Batch 1 Alpha Validation", ""]
for spec in UNIT_SPECS:
copy_before_once(spec)
base_source = Image.open(WORK_DIR / f"source-{spec.key}-base-motion.png").convert("RGBA")
action_source = Image.open(WORK_DIR / f"source-{spec.key}-action.png").convert("RGBA")
base_sheet = build_base_sheet(base_source, spec)
action_sheet = build_action_sheet(action_source, spec)
base_path = UNIT_DIR / f"{spec.key}.png"
action_path = UNIT_DIR / f"{spec.key}-actions.png"
base_sheet.save(base_path, optimize=True)
action_sheet.save(action_path, optimize=True)
contact_path = save_contact_sheet(spec, base_sheet, action_sheet)
before_after_path = save_before_after(spec, base_sheet)
animation_path = save_animation_gif(spec, base_sheet, action_sheet)
base_result = validate_sheet(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
action_result = validate_sheet(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
alpha_lines.extend(
[
f"## {spec.key}",
f"- base: size {base_result[0]}, partial alpha {base_result[1]}, opaque pixels {base_result[2]}",
f"- action: size {action_result[0]}, partial alpha {action_result[1]}, opaque pixels {action_result[2]}",
f"- contact: `{contact_path.name}`",
f"- animation: `{animation_path.name}`",
f"- before/after: `{before_after_path.name}`",
"",
]
)
processed.append((spec, base_sheet, action_sheet))
print(f"Wrote {base_path}")
print(f"Wrote {action_path}")
print(f"Wrote {contact_path}")
print(f"Wrote {before_after_path}")
print(f"Wrote {animation_path}")
overview_path = save_overview(processed)
alpha_report = DOCS_DIR / "handpaint-batch1-alpha-report.md"
alpha_report.write_text("\n".join(alpha_lines), encoding="utf-8")
print(f"Wrote {overview_path}")
print(f"Wrote {alpha_report}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,395 @@
from __future__ import annotations
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
WORK_DIR = ROOT / "tmp" / "liu-bei-handpaint-sample"
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
DOCS_DIR = ROOT / "docs"
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 keyed_rgba(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 > 185) & (b > 185) & (g < 125)
near_key = (r > 145) & (b > 135) & (g < 150) & ((r + b - g * 2) > 180)
alpha = np.where(key | near_key, 0, 255).astype(np.uint8)
body = alpha > 0
magenta_fringe = body & (r > 155) & (b > 135) & (g < 130)
arr[:, :, 0] = np.where(magenta_fringe, np.minimum(arr[:, :, 0], 70), arr[:, :, 0])
arr[:, :, 2] = np.where(magenta_fringe, np.minimum(arr[:, :, 2], 70), arr[:, :, 2])
arr[:, :, 3] = alpha
return Image.fromarray(arr, "RGBA")
def crop_subject(image: Image.Image) -> Image.Image:
rgba = keyed_rgba(image)
rgba = keep_subject_components(rgba)
alpha = np.array(rgba.getchannel("A"))
ys, xs = np.where(alpha > 0)
if len(xs) == 0:
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
pad = 8
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 rgba.crop((left, top, right, bottom))
def keep_subject_components(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)
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
image_center_x = width / 2
image_center_y = height * 0.58
anchor = max(
components,
key=lambda component: component[0]
- abs(component[5] - image_center_x) * 3
- abs(component[6] - image_center_y) * 1.4,
)
anchor_area, left, top, right, bottom, anchor_cx, anchor_cy = anchor
expanded = (left - 70, top - 80, right + 70, bottom + 65)
keep = np.zeros_like(alpha, dtype=bool)
for component in components:
area, c_left, c_top, c_right, c_bottom, cx, cy = component
touches_cell_edge = c_left <= 2 or c_right >= width - 3 or c_top <= 2 or c_bottom >= height - 3
if component != anchor and touches_cell_edge and area < anchor_area * 0.55:
continue
near_anchor = (
c_right >= expanded[0]
and c_left <= expanded[2]
and c_bottom >= expanded[1]
and c_top <= expanded[3]
)
central = 0.04 * width <= cx <= 0.96 * width
substantial = area >= max(55, anchor_area * 0.025)
if component == anchor or (near_anchor and central and substantial):
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 quantize_alpha(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
arr = np.array(rgba)
arr[:, :, 3] = np.where(arr[:, :, 3] > 24, 255, 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def fit_subject(
source: Image.Image,
max_width: int = 286,
max_height: int = 300,
bottom: int = 306,
stretch_x: float = 1.18,
) -> Image.Image:
subject = crop_subject(source)
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 = subject.resize(size, Image.Resampling.LANCZOS)
stretched_width = min(max_width, max(1, round(subject.width * stretch_x)))
if stretched_width != subject.width:
subject = subject.resize((stretched_width, subject.height), Image.Resampling.LANCZOS)
subject = quantize_alpha(subject)
frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
x = (FRAME - subject.width) // 2
y = bottom - subject.height
frame.alpha_composite(subject, (x, y))
frame = quantize_alpha(frame)
return quantize_alpha(keep_subject_components(frame))
def crop_grid(image: Image.Image, row: int, col: int, rows: int, cols: int, x_pad: int = 0, y_pad: int = 0) -> Image.Image:
cell_w = image.width / cols
cell_h = image.height / rows
left = max(0, int(round(col * cell_w)) - x_pad)
top = max(0, int(round(row * cell_h)) - y_pad)
right = min(image.width, int(round((col + 1) * cell_w)) + x_pad)
bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad)
return image.crop((left, top, right, bottom))
def crop_center(image: Image.Image, center_x: int, center_y: int, width: int, height: int) -> Image.Image:
left = max(0, center_x - width // 2)
top = max(0, center_y - height // 2)
right = min(image.width, center_x + width // 2)
bottom = min(image.height, center_y + height // 2)
return image.crop((left, top, right, bottom))
def build_base_sheet(base_source: Image.Image) -> Image.Image:
rows: list[list[Image.Image]] = []
for row in range(4):
frames = [fit_subject(crop_grid(base_source, row, col, 4, 8, 0, 2)) for col in range(8)]
idle = [frames[0].copy() for _ in range(8)]
walk = frames
rows.append(idle + walk)
sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
for row, frames in enumerate(rows):
for col, frame in enumerate(frames):
sheet.alpha_composite(frame, (col * FRAME, row * FRAME))
return sheet
def build_action_source_frames(action_source: Image.Image) -> dict[str, list[Image.Image]]:
attack_frames = [
fit_subject(crop_grid(action_source, 0, col, 3, 10, 10, 28), 300, 300, 306)
for col in range(10)
]
mid = [fit_subject(crop_grid(action_source, 1, col, 3, 5, 18, 26), 286, 300, 306) for col in range(5)]
strategy_seed = mid[:3]
item_seed = mid[3:]
lower_raw = [
crop_center(action_source, 470, 805, 330, 310),
crop_center(action_source, 790, 805, 330, 310),
crop_center(action_source, 1100, 805, 330, 310),
]
lower = [fit_subject(frame, 286, 300, 306) for frame in lower_raw]
return {
"attack": attack_frames,
"strategy": [strategy_seed[index % len(strategy_seed)] for index in (0, 1, 2, 1, 0, 1, 2, 1)],
"item": [item_seed[index % len(item_seed)] for index in range(8)],
"hurt": [lower[0] for _ in range(4)],
"celebrate": [lower[1], lower[2], lower[1], lower[2], lower[1], lower[2]],
}
def direction_frame(frame: Image.Image, direction: str) -> Image.Image:
if direction == "west":
return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
return frame
def build_action_sheet(action_source: Image.Image) -> Image.Image:
source_frames = build_action_source_frames(action_source)
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 source_frames.items():
offset = ACTION_OFFSETS[action]
for index, frame in enumerate(frames):
sheet.alpha_composite(direction_frame(frame, direction), ((offset + index) * FRAME, row * FRAME))
return sheet
def representative_base_frames(base_sheet: Image.Image) -> list[Image.Image]:
picks = [
("south idle", 0, 0),
("south walk", 0, 10),
("east walk", 1, 10),
("north walk", 2, 10),
("west walk", 3, 10),
]
return [base_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for _, row, col in picks]
def representative_action_frames(action_sheet: Image.Image) -> list[Image.Image]:
picks = [
("attack 1", 1, 0),
("attack 4", 1, 3),
("attack 7", 1, 6),
("strategy", 0, ACTION_OFFSETS["strategy"] + 2),
("item", 0, ACTION_OFFSETS["item"] + 1),
("hurt", 0, ACTION_OFFSETS["hurt"]),
("celebrate", 0, ACTION_OFFSETS["celebrate"] + 1),
]
return [action_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for _, row, col in picks]
def checker_background(size: tuple[int, int], tile: int = 16) -> Image.Image:
image = Image.new("RGBA", size, (67, 88, 54, 255))
draw = ImageDraw.Draw(image)
for y in range(0, size[1], tile):
for x in range(0, size[0], tile):
color = (77, 99, 59, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (58, 76, 48, 255)
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color)
return image
def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image:
bg = checker_background((size, size), 8)
scaled = frame.resize((size, size), Image.Resampling.LANCZOS)
bg.alpha_composite(scaled, (0, 0))
return bg
def save_contact_sheet(base_sheet: Image.Image, action_sheet: Image.Image) -> None:
thumbs = representative_base_frames(base_sheet) + representative_action_frames(action_sheet)
labels = [
"idle",
"walk S",
"walk E",
"walk N",
"walk W",
"atk 1",
"atk 4",
"atk 7",
"cmd",
"item",
"hurt",
"win",
]
thumb_size = 132
pad = 18
label_h = 24
cols = 6
rows = 2
out = Image.new("RGBA", (pad + cols * (thumb_size + pad), pad + rows * (thumb_size + label_h + pad)), (20, 24, 22, 255))
draw = ImageDraw.Draw(out)
font = ImageFont.load_default()
for index, frame in enumerate(thumbs):
x = pad + (index % cols) * (thumb_size + pad)
y = pad + (index // cols) * (thumb_size + label_h + pad)
tile = draw_frame_on_bg(frame, thumb_size)
out.alpha_composite(tile, (x, y))
draw.text((x, y + thumb_size + 5), labels[index], fill=(238, 224, 178, 255), font=font)
out.convert("RGB").save(DOCS_DIR / "liu-bei-handpaint-sample-contact.png", optimize=True)
def save_before_after(base_sheet: Image.Image) -> None:
before = Image.open(WORK_DIR / "before-unit-liu-bei.png").convert("RGBA").crop((0, 0, FRAME, FRAME))
after = base_sheet.crop((0, 0, FRAME, FRAME))
out = Image.new("RGBA", (820, 360), (18, 21, 20, 255))
draw = ImageDraw.Draw(out)
font = ImageFont.load_default()
draw.text((30, 18), "Before / After at 313px", fill=(238, 224, 178, 255), font=font)
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 / After at battle scale", fill=(238, 224, 178, 255), font=font)
out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252))
out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252))
draw.text((30, 330), "left: previous checked-in Liu Bei, right: new hand-painted sample", fill=(172, 185, 166, 255), font=font)
out.convert("RGB").save(DOCS_DIR / "liu-bei-handpaint-sample-before-after.png", optimize=True)
def save_animation_gif(base_sheet: Image.Image, action_sheet: Image.Image) -> None:
frames: list[Image.Image] = []
for index in range(10):
canvas = checker_background((540, 190), 10)
idle = base_sheet.crop(((index % 8) * FRAME, 0, ((index % 8) + 1) * FRAME, FRAME))
walk = base_sheet.crop(((8 + index % 8) * FRAME, FRAME, (9 + index % 8) * FRAME, 2 * FRAME))
attack = action_sheet.crop((index * FRAME, FRAME, (index + 1) * FRAME, 2 * FRAME))
for x, frame in ((22, idle), (190, walk), (358, attack)):
scaled = frame.resize((150, 150), Image.Resampling.LANCZOS)
canvas.alpha_composite(scaled, (x, 25))
frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=128))
frames[0].save(
DOCS_DIR / "liu-bei-handpaint-sample-animation.gif",
save_all=True,
append_images=frames[1:],
optimize=True,
duration=110,
loop=0,
)
def validate_sheet(path: Path, expected_size: tuple[int, int]) -> None:
image = Image.open(path).convert("RGBA")
if image.size != expected_size:
raise ValueError(f"{path.name}: expected {expected_size}, got {image.size}")
alpha = np.array(image.getchannel("A"))
partial = np.count_nonzero((alpha > 0) & (alpha < 255))
if partial:
raise ValueError(f"{path.name}: found {partial} partially transparent pixels")
def main() -> None:
base_source = Image.open(WORK_DIR / "source-base-motion.png").convert("RGBA")
action_source = Image.open(WORK_DIR / "source-action.png").convert("RGBA")
base_sheet = build_base_sheet(base_source)
action_sheet = build_action_sheet(action_source)
base_path = UNIT_DIR / "unit-liu-bei.png"
action_path = UNIT_DIR / "unit-liu-bei-actions.png"
base_sheet.save(base_path, optimize=True)
action_sheet.save(action_path, optimize=True)
save_contact_sheet(base_sheet, action_sheet)
save_before_after(base_sheet)
save_animation_gif(base_sheet, action_sheet)
validate_sheet(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
validate_sheet(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
print(f"Wrote {base_path}")
print(f"Wrote {action_path}")
print(f"Wrote {DOCS_DIR / 'liu-bei-handpaint-sample-contact.png'}")
print(f"Wrote {DOCS_DIR / 'liu-bei-handpaint-sample-before-after.png'}")
print(f"Wrote {DOCS_DIR / 'liu-bei-handpaint-sample-animation.gif'}")
if __name__ == "__main__":
main()