140 lines
4.7 KiB
Python
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())
|