Files

212 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()