#!/usr/bin/env python3 """从 assets-src/ 的原始插画生成 public/assets/ 下的透明素材。 为什么要这么处理: 图像生成返回的是**白底**图,需要抠成透明。抠白底分两步,缺一不可—— 1) 边界洪泛:把与图片边界连通的白色变透明(背景主体) 2) 封闭区域补抠:被图形围起来的白色区域与边界不连通,第 1 步碰不到它们。 光环环内、单车车架三角、轮圈内扇形都会因此留成白块(v0.8.3 之前就踩过) 注意:不能简单「全局抠白」,那会把角色的脸/袜子/衣服白一起抠掉。 本脚本按连通域面积处理,并跳过角色躯干区域,保留服装白。 用法(PyPI 的 Pillow): python scripts/prepare-artwork.py """ from collections import deque from pathlib import Path from PIL import Image, ImageDraw ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "assets-src" DST = ROOT / "public" / "assets" SENTINEL = (255, 0, 255) WHITE_TOLERANCE = 18 # 与纯白的最大通道差 def is_white(px): return px[3] > 128 and px[0] > 255 - WHITE_TOLERANCE and px[1] > 255 - WHITE_TOLERANCE and px[2] > 255 - WHITE_TOLERANCE def connected_regions(im, w, h): """列出所有「近白且连通」的区域:[(面积, [(x, y), ...]), ...]""" px = im.load() seen = [[False] * w for _ in range(h)] regions = [] for y in range(h): for x in range(w): if seen[y][x] or not is_white(px[x, y]): continue queue = deque([(x, y)]) seen[y][x] = True cells = [] while queue: cx, cy = queue.popleft() cells.append((cx, cy)) for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)): if 0 <= nx < w and 0 <= ny < h and not seen[ny][nx] and is_white(px[nx, ny]): seen[ny][nx] = True queue.append((nx, ny)) regions.append((len(cells), cells)) return regions def cut_out(src: Path, dst: Path, target_height: int, *, min_area: int, square: bool = False, skip_below: float | None = None, quality: int = 92): """抠白底 -> 裁边 -> 按高度缩放 -> 存 webp。 square: 裁边后补成正方形(头像/图标用,避免圆形裁切与 favicon 变形) skip_below: 只清理位于该纵向比例(0~1)以上的封闭区域;None 表示全部清理 """ im = Image.open(src).convert("RGBA") width, height = im.size # 第 1 步:边界洪泛(连通到边界的背景) rgb = im.convert("RGB") for x, y in ((0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1), (width // 2, 0), (0, height // 2), (width - 1, height // 2), (width // 2, height - 1)): if rgb.getpixel((x, y)) != SENTINEL: ImageDraw.floodfill(rgb, (x, y), SENTINEL, thresh=WHITE_TOLERANCE + 24) sentinel_px = rgb.load() px = im.load() for y in range(height): for x in range(width): if sentinel_px[x, y] == SENTINEL: px[x, y] = (0, 0, 0, 0) # 第 2 步:封闭区域补抠(按面积阈值 + 可选纵向范围) removed = 0 for area, cells in connected_regions(im, width, height): if area < min_area: continue if skip_below is not None and min(cy for _, cy in cells) > height * skip_below: continue # 疑似服装白,保留 for x, y in cells: px[x, y] = (0, 0, 0, 0) removed += area box = im.getbbox() if box: im = im.crop(box) if square: side = max(im.size) canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0)) canvas.paste(im, ((side - im.width) // 2, (side - im.height) // 2)) im = canvas scale = target_height / im.height im = im.resize((round(im.width * scale), target_height), Image.LANCZOS) im.save(dst, "WEBP", quality=quality, method=6) print(f"{dst.name}: {im.size} 补抠封闭白区 {removed}px,体积 {dst.stat().st_size / 1024:.1f}KB") return im def main(): # 立绘:560 高(与 CSS 里 min(40vh, 340px) 的显示尺寸对应,留 1.6 倍余量给高 DPI) figure = cut_out(SRC / "figure-original.png", DST / "shiroko-figure.webp", 560, min_area=150) # 图标:补成正方形再缩到 180,供圆形头像与 favicon 使用 icon = cut_out(SRC / "icon-original.png", DST / "shiroko-icon.webp", 180, min_area=15, square=True) # favicon 两种格式都产出:PNG 64 与 WebP 64。 # PNG 不删——favicon 是浏览器最早的请求,WebP 覆盖虽广但旧浏览器会直接不显示图标, # 留 PNG 作回退代价只有几 KB,换来「任何浏览器都有图标」更稳妥。 icon64 = icon.resize((64, 64), Image.LANCZOS) icon64.save(DST / "favicon-shiroko.png", "PNG", optimize=True) icon64.save(DST / "favicon-shiroko.webp", "WEBP", quality=90, method=6) png_kb = (DST / "favicon-shiroko.png").stat().st_size / 1024 webp_kb = (DST / "favicon-shiroko.webp").stat().st_size / 1024 print(f"favicon-shiroko: 64x64,PNG {png_kb:.1f}KB / WebP {webp_kb:.1f}KB") # 单车:200 高,通体无服装白,全部清理 cut_out(SRC / "bike-original.png", DST / "shiroko-bike.webp", 200, min_area=20) print("完成。视觉上请确认:光环环内、车架三角、轮圈内扇形均为透明,且角色的脸/袜子/衣服完好。") if __name__ == "__main__": main()