make-logo-source.py (3646B)
1 #!/usr/bin/env python3 2 """ 3 Auto-center a logo source image into a 1024x1024 PNG suitable for icns 4 generation by scripts/build-macos.sh. 5 6 Detects the logo's bounding box by thresholding against the corner background 7 colour, squares it up with padding, and pastes onto a square canvas filled 8 with the same background. The build script consumes this as the icns source. 9 10 Usage: 11 scripts/make-logo-source.py SOURCE [--out assets/icon.png] [--padding 0.18] 12 """ 13 import argparse 14 import sys 15 from pathlib import Path 16 17 try: 18 from PIL import Image, ImageChops 19 except ImportError as e: 20 sys.exit(f"missing dep: {e} (need Pillow: pip3 install pillow)") 21 22 23 def average_corner_color(img: Image.Image) -> tuple[int, int, int]: 24 """Sample 8 patches around the edges and return their pixelwise mean.""" 25 w, h = img.size 26 sw = max(20, min(w, h) // 30) 27 patches = [ 28 img.crop((0, 0, sw, sw)), 29 img.crop((w - sw, 0, w, sw)), 30 img.crop((0, h - sw, sw, h)), 31 img.crop((w - sw, h - sw, w, h)), 32 img.crop((0, h // 2 - sw // 2, sw, h // 2 + sw // 2)), 33 img.crop((w - sw, h // 2 - sw // 2, w, h // 2 + sw // 2)), 34 img.crop((w // 2 - sw // 2, 0, w // 2 + sw // 2, sw)), 35 img.crop((w // 2 - sw // 2, h - sw, w // 2 + sw // 2, h)), 36 ] 37 n = len(patches) 38 rs = sum(p.resize((1, 1)).getpixel((0, 0))[0] for p in patches) // n 39 gs = sum(p.resize((1, 1)).getpixel((0, 0))[1] for p in patches) // n 40 bs = sum(p.resize((1, 1)).getpixel((0, 0))[2] for p in patches) // n 41 return rs, gs, bs 42 43 44 def auto_center(src: Path, dst: Path, padding: float, out_size: int) -> None: 45 img = Image.open(src).convert("RGB") 46 w, h = img.size 47 48 bg_color = average_corner_color(img) 49 bg_brightness = max(bg_color) 50 51 # Reduce to per-pixel max channel so logo glow stands out from the dark 52 # background regardless of which RGB channel dominates the logo. 53 r, g, b = img.split() 54 max_chan = ImageChops.lighter(ImageChops.lighter(r, g), b) 55 56 # Threshold to a binary mask, then find its bbox. 57 threshold = min(255, bg_brightness + 25) 58 mask = max_chan.point(lambda v, t=threshold: 255 if v > t else 0, mode="L") 59 bbox = mask.getbbox() 60 if not bbox: 61 sys.exit("could not detect any non-background content in source") 62 63 x0, y0, x1, y1 = bbox 64 cx, cy = (x0 + x1) // 2, (y0 + y1) // 2 65 span = max(x1 - x0, y1 - y0) 66 side = int(span * (1 + padding * 2)) 67 68 print( 69 f" source: {w}x{h} bg≈rgb{bg_color} thresh={threshold}\n" 70 f" bbox: ({x0},{y0})-({x1},{y1}) center=({cx},{cy}) span={span} side={side}" 71 ) 72 73 canvas = Image.new("RGB", (side, side), bg_color) 74 src_x0, src_y0 = cx - side // 2, cy - side // 2 75 src_x1, src_y1 = src_x0 + side, src_y0 + side 76 sx0, sy0 = max(src_x0, 0), max(src_y0, 0) 77 sx1, sy1 = min(src_x1, w), min(src_y1, h) 78 canvas.paste(img.crop((sx0, sy0, sx1, sy1)), (sx0 - src_x0, sy0 - src_y0)) 79 80 canvas = canvas.resize((out_size, out_size), Image.LANCZOS) 81 dst.parent.mkdir(parents=True, exist_ok=True) 82 canvas.save(dst, format="PNG") 83 print(f" wrote: {dst} ({out_size}x{out_size})") 84 85 86 def main() -> None: 87 p = argparse.ArgumentParser() 88 p.add_argument("source", type=Path) 89 p.add_argument("--out", type=Path, default=Path("assets/icon.png")) 90 p.add_argument("--padding", type=float, default=0.18, 91 help="extra space around bbox as fraction of bbox span (default 0.18)") 92 p.add_argument("--size", type=int, default=1024) 93 args = p.parse_args() 94 auto_center(args.source, args.out, args.padding, args.size) 95 96 97 if __name__ == "__main__": 98 main()