新增笔记功能 图标更新
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user