907 lines
34 KiB
Python
907 lines
34 KiB
Python
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()
|