用户反馈的两点: 1. 遮罩看不出效果 → 人物与单车统一全局半透明 settings.decoOpacity(默认 .78) (此前 0.92/0.80 不一致且几乎无感),各自保留边缘渐隐 2. 切视图时人物比例变化 → 删掉卡片视图专用的 300px 高度,两种视图尺寸一致 3. 保留原始素材 → 原图归档 assets-src/,处理流程固化为 scripts/prepare-artwork.py 全局审阅发现并修复: - 无障碍对比度:两套主题的状态色当文字用时远低于 AA(最低 2.56:1)→ 双色体系 (填充色 ≥3:1 保持鲜亮 / 文字色 ≥4.5:1 同色系加深),暗色模式同步 - 暗色模式主题失效:顶栏/卡片/表头/分组行是硬编码白色 → 改为随模式切换的变量 - 平板竖屏 768px 表格需内部横滚 242px → 自动切卡片阈值 700 → 1024 - Cloudflare Worker 快照补齐 dataUpdatedAt/updateIntervalSec/dataFilter - 清理失效 CSS(.logo .halo) 测试脚本修掉两个自身 bug(均为误报):color(srgb/…) 与半透明底的解析、 渐变背景被误判为透明。verify-views 新增对比度断言 8 条,四套合计 89 项全通过。
116 lines
5.0 KiB
Python
116 lines
5.0 KiB
Python
#!/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)
|
|
icon.resize((64, 64), Image.LANCZOS).save(DST / "favicon-shiroko.png", "PNG", optimize=True)
|
|
print(f"favicon-shiroko.png: 64x64,体积 {(DST / 'favicon-shiroko.png').stat().st_size / 1024:.1f}KB")
|
|
# 单车:200 高,通体无服装白,全部清理
|
|
cut_out(SRC / "bike-original.png", DST / "shiroko-bike.webp", 200, min_area=20)
|
|
print("完成。视觉上请确认:光环环内、车架三角、轮圈内扇形均为透明,且角色的脸/袜子/衣服完好。")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|