Expand unified unit sprite style
This commit is contained in:
436
scripts/harmonize-unit-sprites.py
Normal file
436
scripts/harmonize-unit-sprites.py
Normal file
@@ -0,0 +1,436 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops, ImageEnhance, ImageFilter, ImageOps
|
||||
|
||||
|
||||
FRAME_SIZE = 313
|
||||
GRID_SIZE = 4
|
||||
PALETTE_COLORS = 192
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
REFERENCE_SHEET = UNIT_DIR / "unit-cao-cao.png"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameTarget:
|
||||
center_x: float
|
||||
bottom_y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitStyle:
|
||||
scale: float
|
||||
saturation: float
|
||||
contrast: float
|
||||
brightness: float
|
||||
edge_warmth: float
|
||||
rim_color: tuple[int, int, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplateVariant:
|
||||
prefix: str
|
||||
template: str
|
||||
tint: tuple[int, int, int]
|
||||
strength: float
|
||||
saturation: float = 1.0
|
||||
brightness: float = 1.0
|
||||
|
||||
|
||||
TEMPLATE_VARIANTS = (
|
||||
TemplateVariant("unit-shu-archer", "unit-rebel-archer", (54, 132, 82), 0.2, 1.05),
|
||||
TemplateVariant("unit-shu-cavalry", "unit-ma-chao", (58, 126, 78), 0.18, 1.02),
|
||||
TemplateVariant("unit-shu-infantry", "unit-rebel", (56, 132, 78), 0.24, 1.06),
|
||||
TemplateVariant("unit-shu-officer", "unit-guan-yu", (58, 132, 82), 0.16, 1.02),
|
||||
TemplateVariant("unit-shu-spearman", "unit-zhang-fei", (70, 122, 86), 0.14, 1.02),
|
||||
TemplateVariant("unit-shu-strategist", "unit-zhuge-liang", (74, 138, 102), 0.16, 1.03, 1.02),
|
||||
TemplateVariant("unit-wei-archer", "unit-rebel-archer", (70, 84, 118), 0.24, 1.02, 0.98),
|
||||
TemplateVariant("unit-wei-cavalry", "unit-ma-chao", (72, 78, 114), 0.24, 1.0, 0.98),
|
||||
TemplateVariant("unit-wei-infantry", "unit-rebel", (68, 78, 112), 0.26, 1.0, 0.98),
|
||||
TemplateVariant("unit-wei-officer", "unit-cao-cao", (76, 74, 116), 0.18, 1.0, 0.98),
|
||||
TemplateVariant("unit-wei-strategist", "unit-zhuge-liang", (78, 92, 140), 0.22, 1.0, 0.98),
|
||||
TemplateVariant("unit-wu-archer", "unit-rebel-archer", (42, 112, 120), 0.24, 1.05),
|
||||
TemplateVariant("unit-wu-cavalry", "unit-ma-chao", (42, 110, 118), 0.22, 1.04),
|
||||
TemplateVariant("unit-wu-infantry", "unit-rebel", (42, 116, 118), 0.26, 1.05),
|
||||
TemplateVariant("unit-wu-officer", "unit-lu-meng", (46, 116, 128), 0.18, 1.02),
|
||||
TemplateVariant("unit-wu-strategist", "unit-zhuge-liang", (42, 118, 132), 0.22, 1.04),
|
||||
TemplateVariant("unit-nanman-infantry", "unit-rebel", (118, 112, 46), 0.28, 1.08),
|
||||
TemplateVariant("unit-nanman-officer", "unit-meng-huo", (126, 100, 48), 0.2, 1.07),
|
||||
TemplateVariant("unit-nanman-shaman", "unit-zhuge-liang", (118, 114, 48), 0.26, 1.08),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
generic_only = "--generic-only" in sys.argv
|
||||
cleanup_only = "--cleanup-only" in sys.argv
|
||||
optimize_only = "--optimize-only" in sys.argv
|
||||
selected_modes = sum(1 for enabled in (generic_only, cleanup_only, optimize_only) if enabled)
|
||||
if selected_modes > 1:
|
||||
raise ValueError("Use only one of --generic-only, --cleanup-only, or --optimize-only")
|
||||
|
||||
if generic_only:
|
||||
sheets = load_current_base_sheets()
|
||||
write_template_variants(sheets)
|
||||
return
|
||||
|
||||
if cleanup_only:
|
||||
cleanup_current_base_sheets()
|
||||
return
|
||||
|
||||
if optimize_only:
|
||||
optimize_current_unit_pngs()
|
||||
return
|
||||
|
||||
reference = Image.open(REFERENCE_SHEET).convert("RGBA")
|
||||
targets = reference_targets(reference)
|
||||
generated = 0
|
||||
generated_sheets: dict[str, Image.Image] = {}
|
||||
|
||||
for source in sorted(UNIT_DIR.glob("unit-*.png")):
|
||||
if source.name.endswith("-actions.png"):
|
||||
continue
|
||||
|
||||
sheet = Image.open(source).convert("RGBA")
|
||||
if sheet.size != (FRAME_SIZE * GRID_SIZE, FRAME_SIZE * GRID_SIZE):
|
||||
raise ValueError(f"{source.name} has unexpected size {sheet.size}")
|
||||
|
||||
if source.name == REFERENCE_SHEET.name:
|
||||
output = ensure_reference_finish(sheet)
|
||||
else:
|
||||
output = harmonize_sheet(sheet, source.stem, targets)
|
||||
|
||||
save_unit_png(output, source)
|
||||
generated_sheets[source.stem] = output
|
||||
generated += 1
|
||||
|
||||
write_template_variants(generated_sheets)
|
||||
print(f"Harmonized {generated} unit sheets against {REFERENCE_SHEET.name}")
|
||||
|
||||
|
||||
def load_current_base_sheets() -> dict[str, Image.Image]:
|
||||
sheets: dict[str, Image.Image] = {}
|
||||
for source in sorted(UNIT_DIR.glob("unit-*.png")):
|
||||
if source.name.endswith("-actions.png"):
|
||||
continue
|
||||
sheets[source.stem] = Image.open(source).convert("RGBA")
|
||||
return sheets
|
||||
|
||||
|
||||
def cleanup_current_base_sheets() -> None:
|
||||
cleaned = 0
|
||||
for source in sorted(UNIT_DIR.glob("unit-*.png")):
|
||||
if source.name.endswith("-actions.png"):
|
||||
continue
|
||||
sheet = Image.open(source).convert("RGBA")
|
||||
output = Image.new("RGBA", sheet.size, (0, 0, 0, 0))
|
||||
for row in range(GRID_SIZE):
|
||||
for col in range(GRID_SIZE):
|
||||
frame = crop_frame(sheet, row, col)
|
||||
output.alpha_composite(remove_detached_artifacts(clean_alpha(frame)), (col * FRAME_SIZE, row * FRAME_SIZE))
|
||||
save_unit_png(output, source)
|
||||
cleaned += 1
|
||||
print(f"Cleaned detached artifacts in {cleaned} unit sheets")
|
||||
|
||||
|
||||
def optimize_current_unit_pngs() -> None:
|
||||
optimized = 0
|
||||
for source in sorted(UNIT_DIR.glob("unit-*.png")):
|
||||
image = Image.open(source).convert("RGBA")
|
||||
save_unit_png(image, source)
|
||||
optimized += 1
|
||||
print(f"Optimized {optimized} unit PNGs with {PALETTE_COLORS}-color palettes")
|
||||
|
||||
|
||||
def write_template_variants(sheets: dict[str, Image.Image]) -> None:
|
||||
written = 0
|
||||
for source in sorted(UNIT_DIR.glob("unit-*.png")):
|
||||
if source.name.endswith("-actions.png"):
|
||||
continue
|
||||
variant = template_variant_for(source.stem)
|
||||
if not variant:
|
||||
continue
|
||||
template = sheets.get(variant.template)
|
||||
if not template:
|
||||
raise ValueError(f"Missing template sheet {variant.template} for {source.stem}")
|
||||
output = tint_template(template, variant)
|
||||
save_unit_png(output, source)
|
||||
sheets[source.stem] = output
|
||||
written += 1
|
||||
print(f"Wrote {written} high-detail generic unit variants")
|
||||
|
||||
|
||||
def save_unit_png(image: Image.Image, path: Path) -> None:
|
||||
image = image.convert("RGBA")
|
||||
indexed = image.quantize(colors=PALETTE_COLORS, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
|
||||
indexed.save(path, optimize=True)
|
||||
|
||||
|
||||
def template_variant_for(stem: str) -> TemplateVariant | None:
|
||||
for variant in TEMPLATE_VARIANTS:
|
||||
if stem.startswith(variant.prefix) and stem != variant.template:
|
||||
return variant
|
||||
return None
|
||||
|
||||
|
||||
def tint_template(sheet: Image.Image, variant: TemplateVariant) -> Image.Image:
|
||||
rgb = sheet.convert("RGB")
|
||||
alpha = sheet.getchannel("A")
|
||||
luma = ImageOps.grayscale(rgb)
|
||||
dark = tuple(max(0, round(channel * 0.2)) for channel in variant.tint)
|
||||
light = tuple(min(255, round(channel * 1.18 + 84)) for channel in variant.tint)
|
||||
colorized = ImageOps.colorize(luma, black=dark, white=light, mid=variant.tint)
|
||||
mask = alpha.point(lambda value: min(255, round(value * variant.strength)))
|
||||
mixed = Image.composite(colorized, rgb, mask)
|
||||
mixed = ImageEnhance.Color(mixed).enhance(variant.saturation)
|
||||
mixed = ImageEnhance.Brightness(mixed).enhance(variant.brightness)
|
||||
return Image.merge("RGBA", (*mixed.split(), alpha))
|
||||
|
||||
|
||||
def reference_targets(reference: Image.Image) -> list[list[FrameTarget]]:
|
||||
rows: list[list[FrameTarget]] = []
|
||||
for row in range(GRID_SIZE):
|
||||
target_row: list[FrameTarget] = []
|
||||
for col in range(GRID_SIZE):
|
||||
frame = crop_frame(reference, row, col)
|
||||
bbox = alpha_bbox(frame)
|
||||
if not bbox:
|
||||
target_row.append(FrameTarget(FRAME_SIZE / 2, FRAME_SIZE - 8, 172, 282))
|
||||
continue
|
||||
left, top, right, bottom = bbox
|
||||
target_row.append(
|
||||
FrameTarget(
|
||||
center_x=(left + right) / 2,
|
||||
bottom_y=min(FRAME_SIZE - 2, bottom),
|
||||
width=right - left,
|
||||
height=bottom - top,
|
||||
)
|
||||
)
|
||||
rows.append(target_row)
|
||||
return rows
|
||||
|
||||
|
||||
def harmonize_sheet(sheet: Image.Image, stem: str, targets: list[list[FrameTarget]]) -> Image.Image:
|
||||
output = Image.new("RGBA", sheet.size, (0, 0, 0, 0))
|
||||
style = style_for_unit(stem)
|
||||
|
||||
for row in range(GRID_SIZE):
|
||||
for col in range(GRID_SIZE):
|
||||
frame = crop_frame(sheet, row, col)
|
||||
target = targets[row][col]
|
||||
normalized = normalize_frame(frame, target, style)
|
||||
output.alpha_composite(normalized, (col * FRAME_SIZE, row * FRAME_SIZE))
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image:
|
||||
return sheet.crop((col * FRAME_SIZE, row * FRAME_SIZE, (col + 1) * FRAME_SIZE, (row + 1) * FRAME_SIZE)).copy()
|
||||
|
||||
|
||||
def normalize_frame(frame: Image.Image, target: FrameTarget, style: UnitStyle) -> Image.Image:
|
||||
frame = remove_detached_artifacts(clean_alpha(frame))
|
||||
bbox = alpha_bbox(frame)
|
||||
if not bbox:
|
||||
return frame
|
||||
|
||||
subject = frame.crop(bbox)
|
||||
width, height = subject.size
|
||||
fit_width = FRAME_SIZE - 16
|
||||
fit_height = FRAME_SIZE - 8
|
||||
target_height = min(fit_height, max(218, target.height * style.scale))
|
||||
target_width = min(fit_width, max(132, target.width * style.scale))
|
||||
|
||||
scale = 1.0
|
||||
if height > target_height * 1.06 or width > fit_width:
|
||||
scale = min(target_height / height, fit_width / width)
|
||||
elif height < target_height * 0.9:
|
||||
scale = min(target_height / height, 1.13)
|
||||
|
||||
if width * scale > target_width * 1.22:
|
||||
scale = min(scale, (target_width * 1.22) / width)
|
||||
|
||||
new_width = max(1, min(fit_width, round(width * scale)))
|
||||
new_height = max(1, min(fit_height, round(height * scale)))
|
||||
if (new_width, new_height) != subject.size:
|
||||
subject = subject.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
subject = grade_subject(subject, style)
|
||||
canvas = Image.new("RGBA", frame.size, (0, 0, 0, 0))
|
||||
left = round(target.center_x - new_width / 2)
|
||||
top = round(target.bottom_y - new_height)
|
||||
left = max(4, min(FRAME_SIZE - new_width - 4, left))
|
||||
top = max(0, min(FRAME_SIZE - new_height - 2, top))
|
||||
canvas.alpha_composite(subject, (left, top))
|
||||
return finish_frame(canvas, style)
|
||||
|
||||
|
||||
def ensure_reference_finish(sheet: Image.Image) -> Image.Image:
|
||||
style = style_for_unit("unit-cao-cao")
|
||||
output = Image.new("RGBA", sheet.size, (0, 0, 0, 0))
|
||||
for row in range(GRID_SIZE):
|
||||
for col in range(GRID_SIZE):
|
||||
frame = crop_frame(sheet, row, col)
|
||||
output.alpha_composite(finish_frame(remove_detached_artifacts(clean_alpha(frame)), style), (col * FRAME_SIZE, row * FRAME_SIZE))
|
||||
return output
|
||||
|
||||
|
||||
def clean_alpha(image: Image.Image) -> Image.Image:
|
||||
red, green, blue, alpha = image.split()
|
||||
alpha = alpha.point(lambda value: 0 if value < 7 else (255 if value > 248 else value))
|
||||
return Image.merge("RGBA", (red, green, blue, alpha))
|
||||
|
||||
|
||||
def remove_detached_artifacts(image: Image.Image) -> Image.Image:
|
||||
alpha = image.getchannel("A")
|
||||
pixels = alpha.load()
|
||||
width, height = alpha.size
|
||||
visited = bytearray(width * height)
|
||||
components: list[tuple[int, tuple[int, int, int, int], list[tuple[int, int]]]] = []
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
index = y * width + x
|
||||
if visited[index] or pixels[x, y] <= 12:
|
||||
continue
|
||||
stack = [(x, y)]
|
||||
visited[index] = 1
|
||||
points: list[tuple[int, int]] = []
|
||||
left = right = x
|
||||
top = bottom = y
|
||||
|
||||
while stack:
|
||||
px, py = stack.pop()
|
||||
points.append((px, py))
|
||||
left = min(left, px)
|
||||
right = max(right, px)
|
||||
top = min(top, py)
|
||||
bottom = max(bottom, py)
|
||||
for nx, ny in ((px + 1, py), (px - 1, py), (px, py + 1), (px, py - 1)):
|
||||
if nx < 0 or nx >= width or ny < 0 or ny >= height:
|
||||
continue
|
||||
neighbor_index = ny * width + nx
|
||||
if visited[neighbor_index] or pixels[nx, ny] <= 12:
|
||||
continue
|
||||
visited[neighbor_index] = 1
|
||||
stack.append((nx, ny))
|
||||
|
||||
components.append((len(points), (left, top, right + 1, bottom + 1), points))
|
||||
|
||||
if len(components) <= 1:
|
||||
return image
|
||||
|
||||
components.sort(key=lambda component: component[0], reverse=True)
|
||||
largest_area, largest_bbox, _ = components[0]
|
||||
keep_points: set[tuple[int, int]] = set(components[0][2])
|
||||
|
||||
for area, bbox, points in components[1:]:
|
||||
distance = bbox_distance(largest_bbox, bbox)
|
||||
if distance <= 3 or area >= largest_area * 0.18:
|
||||
keep_points.update(points)
|
||||
|
||||
cleaned_alpha = Image.new("L", image.size, 0)
|
||||
cleaned_pixels = cleaned_alpha.load()
|
||||
source_pixels = alpha.load()
|
||||
for x, y in keep_points:
|
||||
cleaned_pixels[x, y] = source_pixels[x, y]
|
||||
|
||||
red, green, blue, _ = image.split()
|
||||
return Image.merge("RGBA", (red, green, blue, cleaned_alpha))
|
||||
|
||||
|
||||
def bbox_distance(left_box: tuple[int, int, int, int], right_box: tuple[int, int, int, int]) -> int:
|
||||
left_a, top_a, right_a, bottom_a = left_box
|
||||
left_b, top_b, right_b, bottom_b = right_box
|
||||
dx = max(left_a - right_b, left_b - right_a, 0)
|
||||
dy = max(top_a - bottom_b, top_b - bottom_a, 0)
|
||||
return max(dx, dy)
|
||||
|
||||
|
||||
def alpha_bbox(image: Image.Image) -> tuple[int, int, int, int] | None:
|
||||
alpha = image.getchannel("A")
|
||||
return alpha.getbbox()
|
||||
|
||||
|
||||
def grade_subject(subject: Image.Image, style: UnitStyle) -> Image.Image:
|
||||
red, green, blue, alpha = subject.split()
|
||||
rgb = Image.merge("RGB", (red, green, blue))
|
||||
rgb = ImageEnhance.Color(rgb).enhance(style.saturation)
|
||||
rgb = ImageEnhance.Contrast(rgb).enhance(style.contrast)
|
||||
rgb = ImageEnhance.Brightness(rgb).enhance(style.brightness)
|
||||
poster = ImageOps.posterize(rgb, 6)
|
||||
rgb = Image.blend(rgb, poster, 0.18)
|
||||
rgb = rgb.filter(ImageFilter.UnsharpMask(radius=1.0, percent=120, threshold=3))
|
||||
return Image.merge("RGBA", (*rgb.split(), alpha))
|
||||
|
||||
|
||||
def finish_frame(frame: Image.Image, style: UnitStyle) -> Image.Image:
|
||||
alpha = frame.getchannel("A")
|
||||
outer_alpha = ImageChops.subtract(alpha.filter(ImageFilter.MaxFilter(7)), alpha)
|
||||
ink_alpha = ImageChops.subtract(alpha.filter(ImageFilter.MaxFilter(3)), alpha)
|
||||
glow_alpha = alpha.filter(ImageFilter.GaussianBlur(2.2))
|
||||
inner_alpha = ImageChops.subtract(alpha, alpha.filter(ImageFilter.MinFilter(3)))
|
||||
|
||||
result = Image.new("RGBA", frame.size, (0, 0, 0, 0))
|
||||
result.alpha_composite(colored_mask(glow_alpha, (*style.rim_color, 34)))
|
||||
result.alpha_composite(colored_mask(outer_alpha, (*style.rim_color, 160)))
|
||||
result.alpha_composite(colored_mask(ink_alpha, (10, 8, 13, 185)))
|
||||
|
||||
warm = colored_mask(inner_alpha, (214, 156, 58, round(34 * style.edge_warmth)))
|
||||
result.alpha_composite(frame)
|
||||
result.alpha_composite(warm)
|
||||
return clean_alpha(result)
|
||||
|
||||
|
||||
def colored_mask(alpha: Image.Image, color: tuple[int, int, int, int]) -> Image.Image:
|
||||
red, green, blue, opacity = color
|
||||
mask = alpha.point(lambda value: min(255, round(value * opacity / 255)))
|
||||
layer = Image.new("RGBA", alpha.size, (red, green, blue, 0))
|
||||
layer.putalpha(mask)
|
||||
return layer
|
||||
|
||||
|
||||
def style_for_unit(stem: str) -> UnitStyle:
|
||||
lower = stem.lower()
|
||||
scale = 0.98
|
||||
saturation = 1.14
|
||||
contrast = 1.08
|
||||
brightness = 1.0
|
||||
edge_warmth = 1.0
|
||||
rim_color = (48, 12, 76)
|
||||
|
||||
if any(token in lower for token in ("archer", "crossbow", "longbow")):
|
||||
scale = 0.94
|
||||
saturation = 1.12
|
||||
elif any(token in lower for token in ("cavalry", "ma-chao", "ma-dai", "lu-bu")):
|
||||
scale = 1.03
|
||||
contrast = 1.1
|
||||
elif any(token in lower for token in ("strategist", "shaman", "zhuge", "sima", "fa-zheng", "ma-liang", "yi-ji")):
|
||||
scale = 0.93
|
||||
brightness = 1.03
|
||||
rim_color = (38, 22, 86)
|
||||
elif any(token in lower for token in ("leader", "officer", "meng-huo", "guan-yu", "zhang-fei", "jiang-wei")):
|
||||
scale = 1.01
|
||||
contrast = 1.1
|
||||
edge_warmth = 1.12
|
||||
|
||||
if "nanman" in lower or "meng-huo" in lower:
|
||||
saturation = 1.18
|
||||
rim_color = (58, 18, 63)
|
||||
elif "wu-" in lower or "lu-meng" in lower:
|
||||
rim_color = (28, 18, 72)
|
||||
elif "wei-" in lower or "cao-cao" in lower or "sima" in lower:
|
||||
rim_color = (43, 10, 78)
|
||||
elif "shu-" in lower or any(token in lower for token in ("liu-bei", "guan-yu", "zhang-fei", "zhao-yun")):
|
||||
rim_color = (34, 24, 72)
|
||||
|
||||
return UnitStyle(scale, saturation, contrast, brightness, edge_warmth, rim_color)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user