176 lines
6.4 KiB
Python
176 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""从归档图标板的 16px 原生规格生成 Plan List 暗金主题进度图标。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from hashlib import sha256
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageFilter
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[4]
|
|
SOURCE = ROOT / '.trellis/tasks/07-17-gilded-wasteland-theme/references/source-assets/wasteland-icon-sheet.webp'
|
|
OUTPUT_DIR = ROOT / 'public/assets/themes/wasteland/icons/plan-progress'
|
|
MANIFEST_PATH = OUTPUT_DIR / 'manifest.json'
|
|
EXPECTED_SOURCE_SHA256 = '482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e'
|
|
CANVAS_SIZE = (20, 20)
|
|
|
|
# 卡片坐标来自归档图标板的几何审计;只读取每张卡片左侧的 16px 原生规格。
|
|
SPECS = (
|
|
{
|
|
'name': 'complete',
|
|
'semantic': '已完成',
|
|
'source_card': 'R2C3 STATUS (ONLINE)',
|
|
'card': (582, 201, 247, 138),
|
|
'usage': 'Plan List 已完成任务的绿色宝石',
|
|
},
|
|
{
|
|
'name': 'remaining',
|
|
'semantic': '未完成',
|
|
'source_card': 'R2C4 LOADING',
|
|
'card': (846, 201, 246, 138),
|
|
'usage': 'Plan List 未完成任务的暗金分段环',
|
|
},
|
|
)
|
|
|
|
|
|
def file_sha256(path: Path) -> str:
|
|
digest = sha256()
|
|
with path.open('rb') as handle:
|
|
for chunk in iter(lambda: handle.read(64 * 1024), b''):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def source_slot(card: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
|
"""返回卡片左侧 16px 规格的检测带,排除标题、尺寸文字和卡片边框。"""
|
|
x, y, _width, height = card
|
|
return x + 14, y + 30, x + 74, min(y + 105, y + height - 24)
|
|
|
|
|
|
def icon_alpha(pixel: tuple[int, int, int]) -> int:
|
|
"""分离暗金高光与状态绿,同时压掉 WebP 暗背景噪点。"""
|
|
red, green, blue = pixel
|
|
gold = red > 44 and green > 31 and red - blue > 15 and green - blue > 7
|
|
pale = red > 118 and green > 86 and red - blue > 28
|
|
status_green = green > 58 and green - red > 5 and green - blue > 10
|
|
if not (gold or pale or status_green):
|
|
return 0
|
|
if status_green:
|
|
score = (green - min(red, blue)) * 4 + green
|
|
else:
|
|
score = (red - blue) * 3 + (green - blue) * 2 + (red + green) // 4
|
|
return max(0, min(255, score))
|
|
|
|
|
|
def alpha_metrics(image: Image.Image) -> tuple[list[int], list[float]]:
|
|
alpha = image.getchannel('A')
|
|
bbox = alpha.getbbox()
|
|
if bbox is None:
|
|
raise RuntimeError('输出图标没有 alpha 主体')
|
|
pixels = alpha.load()
|
|
total = weighted_x = weighted_y = 0
|
|
for y in range(image.height):
|
|
for x in range(image.width):
|
|
value = pixels[x, y]
|
|
if not value:
|
|
continue
|
|
total += value
|
|
weighted_x += x * value
|
|
weighted_y += y * value
|
|
return (
|
|
[bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]],
|
|
[round(weighted_x / total, 3), round(weighted_y / total, 3)],
|
|
)
|
|
|
|
|
|
def extract_icon(sheet: Image.Image, spec: dict[str, object]) -> dict[str, object]:
|
|
slot = source_slot(spec['card'])
|
|
region = sheet.crop(slot).convert('RGB')
|
|
mask = Image.new('L', region.size, 0)
|
|
source_pixels = region.load()
|
|
mask_pixels = mask.load()
|
|
points: list[tuple[int, int]] = []
|
|
|
|
for y in range(region.height):
|
|
for x in range(region.width):
|
|
alpha = icon_alpha(source_pixels[x, y])
|
|
if alpha < 18:
|
|
continue
|
|
mask_pixels[x, y] = alpha
|
|
points.append((x, y))
|
|
|
|
if not points:
|
|
raise RuntimeError(f"未检测到图标主体:{spec['name']}")
|
|
|
|
left = max(0, min(x for x, _y in points) - 3)
|
|
top = max(0, min(y for _x, y in points) - 3)
|
|
right = min(region.width, max(x for x, _y in points) + 4)
|
|
bottom = min(region.height, max(y for _x, y in points) + 4)
|
|
detected = region.crop((left, top, right, bottom)).convert('RGBA')
|
|
detected.putalpha(mask.crop((left, top, right, bottom)).filter(ImageFilter.GaussianBlur(0.12)))
|
|
|
|
target_width, target_height = CANVAS_SIZE
|
|
scale = min((target_width - 2) / detected.width, (target_height - 2) / detected.height)
|
|
resized = detected.resize(
|
|
(max(1, round(detected.width * scale)), max(1, round(detected.height * scale))),
|
|
Image.Resampling.LANCZOS,
|
|
)
|
|
output = Image.new('RGBA', CANVAS_SIZE, (0, 0, 0, 0))
|
|
output.alpha_composite(resized, ((target_width - resized.width) // 2, (target_height - resized.height) // 2))
|
|
|
|
# 按 alpha 加权重心做最后 1px 级光学校正。
|
|
_bbox, center = alpha_metrics(output)
|
|
offset_x = round((target_width - 1) / 2 - center[0])
|
|
offset_y = round((target_height - 1) / 2 - center[1])
|
|
if offset_x or offset_y:
|
|
centered = Image.new('RGBA', CANVAS_SIZE, (0, 0, 0, 0))
|
|
centered.alpha_composite(output, (offset_x, offset_y))
|
|
output = centered
|
|
|
|
output_path = OUTPUT_DIR / f"{spec['name']}.png"
|
|
output.save(output_path, optimize=True)
|
|
alpha_bbox, alpha_center = alpha_metrics(output)
|
|
return {
|
|
'name': spec['name'],
|
|
'semantic': spec['semantic'],
|
|
'source': SOURCE.name,
|
|
'source_sha256': EXPECTED_SOURCE_SHA256,
|
|
'source_card': spec['source_card'],
|
|
'source_variant': '16px',
|
|
'slot_box': [slot[0], slot[1], slot[2] - slot[0], slot[3] - slot[1]],
|
|
'detected_box': [slot[0] + left, slot[1] + top, right - left, bottom - top],
|
|
'output': f"icons/plan-progress/{spec['name']}.png",
|
|
'size': list(CANVAS_SIZE),
|
|
'alpha_bbox': alpha_bbox,
|
|
'alpha_center': alpha_center,
|
|
'sha256': file_sha256(output_path),
|
|
'usage': spec['usage'],
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
actual_sha256 = file_sha256(SOURCE)
|
|
if actual_sha256 != EXPECTED_SOURCE_SHA256:
|
|
raise RuntimeError(f'图标板 hash 不匹配:{actual_sha256}')
|
|
sheet = Image.open(SOURCE).convert('RGB')
|
|
if sheet.size != (1672, 941):
|
|
raise RuntimeError(f'图标板尺寸不匹配:{sheet.size}')
|
|
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
assets = [extract_icon(sheet, spec) for spec in SPECS]
|
|
manifest = {
|
|
'source': SOURCE.name,
|
|
'source_path': str(SOURCE.relative_to(ROOT)),
|
|
'source_sha256': EXPECTED_SOURCE_SHA256,
|
|
'source_size': list(sheet.size),
|
|
'assets': assets,
|
|
}
|
|
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|