navi

Obsidian-style interactive graph viewer for org-roam — native window, no Emacs package required.
Log | Files | Refs | README

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()