#!/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()