Expand unified unit sprite style

This commit is contained in:
2026-06-28 20:04:08 +09:00
parent 42c849eb2a
commit 49ce0746cd
187 changed files with 669 additions and 15 deletions

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter
FRAME_SIZE = 313
PALETTE_COLORS = 192
ROOT = Path(__file__).resolve().parents[1]
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
@@ -47,12 +49,13 @@ def main() -> None:
continue
sheet = Image.open(source).convert("RGBA")
output = Image.new("RGBA", (FRAME_SIZE * len(ACTION_ORDER), FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0))
profile = action_profile(source.stem)
for row, direction in enumerate(DIRECTIONS):
for col, action in enumerate(ACTION_ORDER):
frame = crop_frame(sheet, row, FRAME_BY_ACTION[action])
if action == "attack":
action_frame = make_attack(frame, direction)
action_frame = make_attack(frame, direction, profile)
elif action == "strategy":
action_frame = make_strategy(frame, direction)
elif action == "item":
@@ -61,7 +64,26 @@ def main() -> None:
action_frame = make_hurt(frame, direction)
output.alpha_composite(action_frame, (col * FRAME_SIZE, row * FRAME_SIZE))
output.save(source.with_name(f"{source.stem}-actions.png"))
save_unit_png(output, source.with_name(f"{source.stem}-actions.png"))
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 action_profile(stem: str) -> str:
lower = stem.lower()
if any(token in lower for token in ("archer", "crossbow", "longbow")):
return "ranged"
if any(token in lower for token in ("strategist", "shaman", "zhuge", "sima", "fa-zheng", "ma-liang", "yi-ji")):
return "strategy"
if any(token in lower for token in ("cavalry", "ma-chao", "ma-dai", "lu-bu")):
return "cavalry"
if any(token in lower for token in ("spearman", "guan-yu", "zhang-fei", "jiang-wei", "huang-zhong", "zhao-yun")):
return "polearm"
return "blade"
def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image:
@@ -91,7 +113,17 @@ def underlay(frame: Image.Image, color: tuple[int, int, int, int], blur: int, ex
return result
def make_attack(frame: Image.Image, direction: str) -> Image.Image:
def make_attack(frame: Image.Image, direction: str, profile: str) -> Image.Image:
if profile == "ranged":
return make_ranged_attack(frame, direction)
if profile == "strategy":
return make_strategy_attack(frame, direction)
if profile == "cavalry":
return make_charge_attack(frame, direction)
return make_blade_attack(frame, direction, profile)
def make_blade_attack(frame: Image.Image, direction: str, profile: str) -> Image.Image:
dx, dy = FORWARD[direction]
body = offset_subject(frame, dx, dy)
body = underlay(body, (255, 224, 120, 92), 4, 1)
@@ -107,9 +139,10 @@ def make_attack(frame: Image.Image, direction: str) -> Image.Image:
else:
points = [(74, 202), (166, 112), (248, 198)]
draw.line(points, fill=(255, 247, 214, 218), width=15, joint="curve")
draw.line(points, fill=(98, 210, 255, 178), width=6, joint="curve")
draw.line(points, fill=(255, 178, 86, 158), width=2, joint="curve")
width = 18 if profile == "polearm" else 15
draw.line(points, fill=(255, 247, 214, 224), width=width, joint="curve")
draw.line(points, fill=(98, 210, 255, 184), width=7 if profile == "polearm" else 6, joint="curve")
draw.line(points, fill=(255, 178, 86, 166), width=3 if profile == "polearm" else 2, joint="curve")
slash = slash.filter(ImageFilter.GaussianBlur(0.45))
result = Image.new("RGBA", frame.size, (0, 0, 0, 0))
@@ -118,6 +151,105 @@ def make_attack(frame: Image.Image, direction: str) -> Image.Image:
return result
def make_ranged_attack(frame: Image.Image, direction: str) -> Image.Image:
dx, dy = FORWARD[direction]
body = underlay(offset_subject(frame, round(dx * 0.45), round(dy * 0.45)), (255, 221, 96, 78), 4, 1)
effect = Image.new("RGBA", frame.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(effect)
if direction == "east":
shaft = (134, 124, 282, 102)
tip = [(282, 102), (266, 94), (269, 111)]
fletch = ((132, 124), (111, 118), (115, 132))
bow = (84, 74, 158, 198, -54, 62)
elif direction == "west":
shaft = (179, 124, 31, 102)
tip = [(31, 102), (47, 94), (44, 111)]
fletch = ((181, 124), (202, 118), (198, 132))
bow = (155, 74, 229, 198, 172, 286)
elif direction == "south":
shaft = (140, 118, 176, 274)
tip = [(176, 274), (163, 259), (184, 257)]
fletch = ((140, 118), (131, 97), (150, 101))
bow = (91, 80, 213, 176, 24, 145)
else:
shaft = (172, 190, 136, 34)
tip = [(136, 34), (129, 51), (148, 48)]
fletch = ((172, 190), (182, 211), (163, 207))
bow = (91, 128, 213, 224, 205, 326)
draw.arc(bow[:4], start=bow[4], end=bow[5], fill=(250, 214, 102, 190), width=5)
draw.line(shaft, fill=(255, 252, 225, 224), width=7)
draw.line(shaft, fill=(92, 210, 255, 150), width=3)
draw.polygon(tip, fill=(255, 245, 207, 230))
draw.polygon(fletch, fill=(255, 196, 88, 196))
for offset in (-14, 14):
if direction in ("east", "west"):
draw.line((shaft[0], shaft[1] + offset, shaft[2], shaft[3] + offset), fill=(255, 230, 128, 70), width=2)
else:
draw.line((shaft[0] + offset, shaft[1], shaft[2] + offset, shaft[3]), fill=(255, 230, 128, 70), width=2)
result = Image.new("RGBA", frame.size, (0, 0, 0, 0))
result.alpha_composite(effect.filter(ImageFilter.GaussianBlur(0.25)))
result.alpha_composite(body)
return result
def make_strategy_attack(frame: Image.Image, direction: str) -> Image.Image:
dx, dy = FORWARD[direction]
body = underlay(offset_subject(frame, round(dx * 0.35), round(dy * 0.35)), (92, 180, 255, 92), 6, 2)
effect = Image.new("RGBA", frame.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(effect)
center = (156 + round(dx * 0.45), 150 + round(dy * 0.45))
for radius, alpha in ((78, 82), (48, 126), (22, 180)):
draw.ellipse(
(center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius),
outline=(108, 210, 255, alpha),
width=5,
)
for angle in range(0, 360, 45):
x = center[0] + int(74 * math.cos(math.radians(angle)))
y = center[1] + int(74 * math.sin(math.radians(angle)))
draw.polygon([(x, y - 8), (x + 5, y), (x, y + 8), (x - 5, y)], fill=(255, 230, 132, 156))
result = Image.new("RGBA", frame.size, (0, 0, 0, 0))
result.alpha_composite(effect.filter(ImageFilter.GaussianBlur(0.35)))
result.alpha_composite(body)
return result
def make_charge_attack(frame: Image.Image, direction: str) -> Image.Image:
dx, dy = FORWARD[direction]
body = underlay(offset_subject(frame, round(dx * 1.2), round(dy * 1.2)), (255, 188, 84, 88), 5, 2)
effect = Image.new("RGBA", frame.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(effect)
if direction == "east":
lines = [((38, 96), (188, 82)), ((28, 154), (214, 146)), ((56, 222), (180, 204))]
spear = (128, 132, 288, 98)
elif direction == "west":
lines = [((275, 96), (125, 82)), ((285, 154), (99, 146)), ((257, 222), (133, 204))]
spear = (185, 132, 25, 98)
elif direction == "south":
lines = [((86, 28), (98, 188)), ((154, 24), (148, 214)), ((226, 52), (205, 180))]
spear = (138, 120, 180, 286)
else:
lines = [((86, 285), (98, 125)), ((154, 289), (148, 99)), ((226, 261), (205, 133))]
spear = (175, 192, 133, 26)
for line in lines:
draw.line(line, fill=(255, 204, 102, 118), width=8)
draw.line(line, fill=(255, 245, 210, 120), width=3)
draw.line(spear, fill=(255, 250, 218, 218), width=8)
draw.line(spear, fill=(255, 152, 58, 166), width=3)
result = Image.new("RGBA", frame.size, (0, 0, 0, 0))
result.alpha_composite(effect.filter(ImageFilter.GaussianBlur(0.3)))
result.alpha_composite(body)
return result
def make_strategy(frame: Image.Image, direction: str) -> Image.Image:
aura = Image.new("RGBA", frame.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(aura)

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