Expand unified unit sprite style
@@ -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)
|
||||
|
||||
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()
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 359 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 1013 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 1003 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 359 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 359 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 359 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 905 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 720 KiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 992 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 1020 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 903 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 988 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 409 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 409 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 409 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 1018 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 274 KiB |