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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -0,0 +1,36 @@
# Handpaint Batch 1 Alpha Validation
## unit-guan-yu
- base: size (5008, 1252), partial alpha 0, opaque pixels 2269236
- action: size (11268, 1252), partial alpha 0, opaque pixels 4929316
- contact: `handpaint-batch1-unit-guan-yu-contact.png`
- animation: `handpaint-batch1-unit-guan-yu-animation.gif`
- before/after: `handpaint-batch1-unit-guan-yu-before-after.png`
## unit-zhang-fei
- base: size (5008, 1252), partial alpha 0, opaque pixels 2215087
- action: size (11268, 1252), partial alpha 0, opaque pixels 4580452
- contact: `handpaint-batch1-unit-zhang-fei-contact.png`
- animation: `handpaint-batch1-unit-zhang-fei-animation.gif`
- before/after: `handpaint-batch1-unit-zhang-fei-before-after.png`
## unit-rebel
- base: size (5008, 1252), partial alpha 0, opaque pixels 2168171
- action: size (11268, 1252), partial alpha 0, opaque pixels 5156024
- contact: `handpaint-batch1-unit-rebel-contact.png`
- animation: `handpaint-batch1-unit-rebel-animation.gif`
- before/after: `handpaint-batch1-unit-rebel-before-after.png`
## unit-rebel-archer
- base: size (5008, 1252), partial alpha 0, opaque pixels 1902601
- action: size (11268, 1252), partial alpha 0, opaque pixels 4808052
- contact: `handpaint-batch1-unit-rebel-archer-contact.png`
- animation: `handpaint-batch1-unit-rebel-archer-animation.gif`
- before/after: `handpaint-batch1-unit-rebel-archer-before-after.png`
## unit-rebel-cavalry
- base: size (5008, 1252), partial alpha 0, opaque pixels 1820794
- action: size (11268, 1252), partial alpha 0, opaque pixels 4278832
- contact: `handpaint-batch1-unit-rebel-cavalry-contact.png`
- animation: `handpaint-batch1-unit-rebel-cavalry-animation.gif`
- before/after: `handpaint-batch1-unit-rebel-cavalry-before-after.png`

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -0,0 +1,53 @@
# Tactical Unit Sprite Art Direction
## Goal
Create new frame-by-frame raster unit sprites for the battle map. The sprites must read clearly at the live desktop battle scale, where a unit is displayed around a 50px tile, while still looking intentional when inspected at the original 313x313 frame size.
This pass is not a filter pass, alpha fix, vector redraw, or procedural shape generator. It is an art-directed raster repaint pass. Each pose should feel drawn as a distinct frame, with changes in stance, cloth, weapon position, and motion energy.
## Copyright Boundary
The visual mood may reference historical Three Kingdoms tactical RPGs: heroic silhouettes, ornate but readable armor, strong faction colors, and dramatic weapon poses.
Do not copy KOEI artwork, character portraits, logos, sprite silhouettes, UI assets, or exact costume designs. Use original silhouettes, original clothing details, and original frame layouts made for this project.
## Battle Readability Rules
- The body pixels of the unit must be fully opaque.
- The surrounding frame background may be transparent after extraction.
- At 50px display size, the silhouette must be readable before interior detail.
- Avoid hairline strokes, pale low-contrast outlines, watercolor transparency, glow-only shapes, or thin ornamental clutter.
- Use a dark outer contour, large color planes, and one or two strong highlight areas.
- Weapon identity must be visible from the silhouette.
- Friendly heroes should be richer and more ornate than common soldiers, but not so dense that the 50px battle read is lost.
## Liu Bei Sample Direction
Liu Bei is the approval-gate sample.
Core read:
- Noble commander with a sword.
- Deep green outer robe, warm cream inner robe, muted bronze/gold armor trim.
- Upright, restrained, humane leader posture rather than brute force.
- Sword should be readable in attack frames and visible enough in idle/walk frames.
Frame requirements:
- Base sheet: 4 directions x 16 frames.
- Per direction: idle 8 frames, walk 8 frames.
- Action sheet: 4 directions x 36 frames.
- Per direction: attack 10, strategy 8, item 8, hurt 4, celebrate 6.
- Frame size remains 313x313.
- Frames should preserve the same character but vary pose, robe hem, sleeves, sword angle, and weight shift.
## Approval Gate
Only Liu Bei may be produced in this sample phase. Do not expand to Guan Yu, Zhang Fei, rebel soldiers, or the full roster until the sample has been reviewed and approved.
Required sample outputs:
- 313x313 contact sheet for Liu Bei.
- Animated GIF or browser capture for idle/walk/action.
- 100% desktop browser screenshot from the battle map.
- Before/after comparison against the current checked-in Liu Bei sprite.
No deployment, commit, or push is allowed before sample approval.

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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 KiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 KiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 KiB

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -7,7 +7,6 @@ const unitWalkFrameCount = 8;
export const unitBaseFramesPerDirection = unitIdleFrameCount + unitWalkFrameCount;
export const unitSheetFrameSize = 313;
const unitWalkFrameRate = 10;
const unitIdleFrameRate = 5;
const unitSheetRows: Record<UnitDirection, number> = {
south: 0,
east: 1,
@@ -99,10 +98,12 @@ export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable<string>
key,
frame: unitSheetRows[direction] * unitBaseFramesPerDirection + unitIdleFrameCount + frameIndex
}));
const idleFrames = Array.from({ length: unitIdleFrameCount }, (_, frameIndex) => ({
key,
frame: unitSheetRows[direction] * unitBaseFramesPerDirection + frameIndex
}));
const idleFrames = [
{
key,
frame: unitSheetRows[direction] * unitBaseFramesPerDirection
}
];
const walkAnimationKey = `${key}-walk-${direction}`;
const idleAnimationKey = `${key}-idle-${direction}`;
@@ -119,7 +120,7 @@ export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable<string>
scene.anims.create({
key: idleAnimationKey,
frames: idleFrames,
frameRate: unitIdleFrameRate,
frameRate: 1,
repeat: -1
});
}

Some files were not shown because too many files have changed in this diff Show More