Files
zyt/app/scripts/generate_brand_assets.py
T
2026-08-22 10:46:13 +08:00

140 lines
4.7 KiB
Python

"""Generate deterministic application-brand assets from the approved master PNG."""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MASTER = PROJECT_ROOT / "resources" / "branding" / "brand-master.png"
BRANDING_ROOT = PROJECT_ROOT / "resources" / "branding"
VIDEO_PUBLIC_ROOT = PROJECT_ROOT / "video_companion" / "public"
ICON_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256)
def _transparent_connected_background(image: Image.Image) -> Image.Image:
"""Remove only near-white pixels connected to the crop boundary.
The logo contains intentional white ECG strokes. A global color-key would
erase them, whereas a connected-background mask preserves enclosed whites.
"""
rgb = image.convert("RGB")
candidates = Image.new("L", rgb.size)
candidates.putdata(
[
255
if min(pixel) >= 185 and max(pixel) - min(pixel) <= 70
else 0
for pixel in rgb.getdata()
]
)
ImageDraw.floodfill(candidates, (0, 0), 128, thresh=0)
alpha = candidates.point(lambda value: 0 if value == 128 else 255)
rgba = rgb.convert("RGBA")
rgba.putalpha(alpha)
return rgba
def _square_icon(image: Image.Image, size: int = 1024, padding: int = 72) -> Image.Image:
available = size - padding * 2
scale = min(available / image.width, available / image.height)
rendered = image.resize(
(round(image.width * scale), round(image.height * scale)),
Image.Resampling.LANCZOS,
)
tile = Image.new("RGBA", (size, size), (0, 0, 0, 0))
tile_mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(tile_mask).rounded_rectangle(
(24, 24, size - 24, size - 24),
radius=190,
fill=255,
)
white_tile = Image.new("RGBA", (size, size), (255, 255, 255, 255))
tile.paste(white_tile, mask=tile_mask)
tile.alpha_composite(
rendered.convert("RGBA"),
((size - rendered.width) // 2, (size - rendered.height) // 2),
)
return tile
def generate(master_path: Path) -> tuple[Path, ...]:
master = Image.open(master_path).convert("RGB")
if master.size != (1254, 1254):
raise ValueError(f"expected a 1254x1254 brand master, got {master.size}")
BRANDING_ROOT.mkdir(parents=True, exist_ok=True)
VIDEO_PUBLIC_ROOT.mkdir(parents=True, exist_ok=True)
lockup_bbox = _transparent_connected_background(master).getchannel("A").getbbox()
if lockup_bbox is None:
raise ValueError("brand lockup extraction produced an empty image")
left, top, right, bottom = lockup_bbox
full_lockup = master.crop(
(
max(0, left - 28),
max(0, top - 28),
min(master.width, right + 28),
min(master.height, bottom + 28),
)
)
# The supplied artwork places the pictorial mark wholly above y=720. The
# crop intentionally excludes the Chinese and English lockup for legible
# Windows/macOS small icons.
mark_crop = master.crop((300, 110, 980, 720))
mark_bbox = _transparent_connected_background(mark_crop).getchannel("A").getbbox()
if mark_bbox is None:
raise ValueError("application icon extraction produced an empty image")
left, top, right, bottom = mark_bbox
app_icon = _square_icon(
mark_crop.crop(
(
max(0, left - 12),
max(0, top - 12),
min(mark_crop.width, right + 12),
min(mark_crop.height, bottom + 12),
)
)
)
lockup_path = BRANDING_ROOT / "brand-lockup.png"
icon_png_path = BRANDING_ROOT / "app-icon.png"
icon_ico_path = BRANDING_ROOT / "app-icon.ico"
icon_icns_path = BRANDING_ROOT / "app-icon.icns"
favicon_path = VIDEO_PUBLIC_ROOT / "favicon.png"
full_lockup.save(lockup_path, optimize=True)
app_icon.save(icon_png_path, optimize=True)
app_icon.save(
icon_ico_path,
format="ICO",
sizes=[(size, size) for size in ICON_SIZES],
)
app_icon.save(icon_icns_path, format="ICNS")
app_icon.resize((64, 64), Image.Resampling.LANCZOS).save(
favicon_path,
optimize=True,
)
return lockup_path, icon_png_path, icon_ico_path, icon_icns_path, favicon_path
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--master", type=Path, default=DEFAULT_MASTER)
args = parser.parse_args()
master_path = args.master.resolve()
if not master_path.is_file():
parser.error(f"brand master is missing: {master_path}")
for output in generate(master_path):
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())