更新
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user