# -*- coding: utf-8 -*- """ 生成 App 图标资源(Android / iOS / Web 通用)。 背景: 品牌原图 AI-accounting-soft-uniApp/static/images/logo.png 是「熊头+3只狗+5个大字」 的贴纸海报,带虚化底纹,不适合直接当图标(缩小后文字糊成一团、背景去不干净)。 因此改用同画风的专用图标素材(熊头单主体),由本脚本完成: 1. 去除素材右下角的 AI 生成水印(用上方像素向下延展填充,适配平滑渐变底) 2. 居中裁切、按比例缩放 3. 输出两种产物 产物: assets/icon/app_icon.png 1024x1024,无透明通道 → iOS / Android 传统图标 / Web assets/icon/app_icon_fg.png 1024x1024,带透明通道 → Android 自适应图标前景层 assets/images/logo.png 512x512,带透明通道 → App 内品牌标识(登录页圆形 Logo) 登录页的 Logo 用透明底:外层是白色圆形,放进不透明的方图会露出四角(方图 对角线比圆直径长),看起来像贴了张方照片。 用法: python tools/make_app_icon.py # 生成正式产物 python tools/make_app_icon.py --preview # 只输出效果预览图 """ from __future__ import annotations import argparse from pathlib import Path import numpy as np from PIL import Image, ImageDraw ROOT = Path(__file__).resolve().parent.parent DEFAULT_SRC = ROOT / "assets" / "icon" / "_source" / "raw_bear.png" OUT_DIR = ROOT / "assets" / "icon" LOGO_OUT = ROOT / "assets" / "images" / "logo.png" CANVAS = 1024 BG_COLOR = (255, 246, 236) # 暖奶油色,与品牌焦糖色 #C8956E 同色系 ICON_FILL = 0.74 # 正式图标里主体的占比 FG_FILL = 0.60 # 自适应图标前景层占比(Android 安全区为画布的 66%) LOGO_CANVAS = 512 # 登录页最大显示 120pt,512 够 4x 屏 LOGO_FILL = 0.86 # 品牌标识的占比,比自适应前景层饱满一些 BG_THRESH = 70 # 判定「这是背景」的色差阈值(各通道绝对差之和) # 水印区域(右下角),留够边距避免碰到主体 WM_BOX = (850, 912, 1024, 1024) def remove_watermark(im: Image.Image, box: tuple[int, int, int, int]) -> Image.Image: """把 box 区域用其上方一行的像素向下延展填充,抹掉水印。""" x0, y0, x1, y1 = box row = im.crop((x0, y0 - 1, x1, y0)).resize((x1 - x0, y1 - y0), Image.NEAREST) out = im.copy() out.paste(row, (x0, y0)) return out def background_mask(im: Image.Image, thresh: int) -> np.ndarray: """返回「属于背景」的布尔矩阵:与四角平均色的色差小于阈值。""" arr = np.asarray(im).astype(np.int16) h, w, _ = arr.shape corners = np.stack([ arr[0, 0], arr[0, w - 1], arr[h - 1, 0], arr[h - 1, w - 1], arr[2, 2], arr[2, w - 3], arr[h - 3, 2], arr[h - 3, w - 3], ]).astype(np.int16) seed = corners.mean(axis=0) diff = np.abs(arr - seed).sum(axis=2) return diff <= thresh def to_foreground(im: Image.Image, mask: np.ndarray) -> Image.Image: """按背景掩码抠出主体,返回带 alpha 的图。""" rgba = im.convert("RGBA") a = np.asarray(rgba).copy() a[..., 3] = np.where(mask, 0, 255) out = Image.fromarray(a, "RGBA") bbox = out.getbbox() return out.crop(bbox) if bbox else out def fit(art: Image.Image, size: int, fill: float, bg: tuple[int, int, int, int] | None) -> Image.Image: """等比缩放 art 至画布 size 的 fill 比例,居中合成。bg=None 表示透明底。""" box = int(size * fill) scale = min(box / art.width, box / art.height) art = art.convert("RGBA").resize( (max(1, round(art.width * scale)), max(1, round(art.height * scale))), Image.LANCZOS, ) canvas = Image.new("RGBA", (size, size), bg if bg else (0, 0, 0, 0)) canvas.alpha_composite(art, ((size - art.width) // 2, (size - art.height) // 2)) return canvas def build(src: Path): im = Image.open(src).convert("RGB") print(f"source: {src.name} {im.size}") cleaned = remove_watermark(im, WM_BOX) mask = background_mask(cleaned, BG_THRESH) print(f"background pixels: {mask.mean():.1%}") solid_subject = Image.fromarray( np.where(mask[..., None], np.array(BG_COLOR, np.uint8), np.asarray(cleaned)).astype(np.uint8), "RGB", ) bbox = background_mask(solid_subject, BG_THRESH) ys, xs = np.nonzero(~bbox) subject = solid_subject.crop((xs.min(), ys.min(), xs.max() + 1, ys.max() + 1)) fg_subject = to_foreground(cleaned, mask) print(f"subject bbox: {subject.size} fg bbox: {fg_subject.size}") icon = fit(subject, CANVAS, ICON_FILL, (*BG_COLOR, 255)).convert("RGB") fg = fit(fg_subject, CANVAS, FG_FILL, None) logo = fit(fg_subject, LOGO_CANVAS, LOGO_FILL, None) return icon, fg, logo def checkerboard(size: int, square: int = 16) -> Image.Image: board = Image.new("RGB", (size, size), (255, 255, 255)) d = ImageDraw.Draw(board) for y in range(0, size, square): for x in range(0, size, square): if (x // square + y // square) % 2: d.rectangle([x, y, x + square - 1, y + square - 1], fill=(205, 205, 205)) return board def preview(icon: Image.Image, fg: Image.Image) -> None: cell = 240 sheet = Image.new("RGB", (cell * 4 + 50, cell + 50), (235, 235, 235)) sheet.paste(icon.resize((cell, cell), Image.LANCZOS), (10, 30)) tiny = icon.resize((48, 48), Image.LANCZOS).resize((cell, cell), Image.NEAREST) sheet.paste(tiny, (cell + 20, 30)) adaptive = Image.new("RGB", (CANVAS, CANVAS), BG_COLOR) adaptive.paste(fg, (0, 0), fg) circ = Image.new("L", (CANVAS, CANVAS), 0) ImageDraw.Draw(circ).ellipse([60, 60, CANVAS - 60, CANVAS - 60], fill=255) circle_bg = Image.new("RGB", (CANVAS, CANVAS), (255, 255, 255)) circle_bg.paste(adaptive, (0, 0), circ) sheet.paste(circle_bg.resize((cell, cell), Image.LANCZOS), (cell * 2 + 30, 30)) board = checkerboard(cell) board.paste(fg.resize((cell, cell), Image.LANCZOS), (0, 0), fg.resize((cell, cell), Image.LANCZOS)) sheet.paste(board, (cell * 3 + 40, 30)) d = ImageDraw.Draw(sheet) for i, label in enumerate(["1: icon 1024", "2: at 48px", "3: adaptive(circle)", "4: fg alpha"]): d.text((10 + (cell + 10) * i, 10), label, fill=(0, 0, 0)) out = ROOT / "tools" / "_preview.png" sheet.save(out) print("preview ->", out) CHECK_TARGETS = [ "assets/icon/app_icon.png", "assets/icon/app_icon_fg.png", "assets/images/logo.png", "ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png", "android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png", "android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png", "web/icons/Icon-512.png", ] def check() -> None: """核对已生成的图标:iOS 的 1024 图必须是 RGB(无 alpha),否则上架报 ITMS-90717。""" for rel in CHECK_TARGETS: p = ROOT / rel if not p.exists(): print(f"MISSING {rel}") continue with Image.open(p) as im: flag = "" if "ios" in rel and im.mode != "RGB": flag = " <-- 必须无 alpha!" print(f"{im.mode:<6} {str(im.size):<12} {rel}{flag}") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--src", type=Path, default=DEFAULT_SRC) ap.add_argument("--preview", action="store_true", help="只输出效果预览图") ap.add_argument("--check", action="store_true", help="核对已生成的图标产物") args = ap.parse_args() if args.check: check() return icon, fg, logo = build(args.src) if args.preview: preview(icon, fg) return OUT_DIR.mkdir(parents=True, exist_ok=True) icon.save(OUT_DIR / "app_icon.png", "PNG", optimize=True) fg.save(OUT_DIR / "app_icon_fg.png", "PNG", optimize=True) LOGO_OUT.parent.mkdir(parents=True, exist_ok=True) logo.save(LOGO_OUT, "PNG", optimize=True) print("->", OUT_DIR / "app_icon.png", icon.size) print("->", OUT_DIR / "app_icon_fg.png", fg.size) print("->", LOGO_OUT, logo.size) if __name__ == "__main__": main()