irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

irongit/scripts/gen-brand.py
136 lines5.5 KBPython
1#!/usr/bin/env python3
2"""Builds the irongit icons from the logo in brand/.
3
4brand/logo-source.png is used as is when it already has a transparent
5background; near-opaque pixels are snapped to fully opaque so the mark never
6lets a background tint through. A source on a flat green screen (PNG without
7alpha, or brand/logo-source.jpg) is keyed instead: the green is removed by how
8green each pixel is, and edge pixels are unmixed (P = a*F + (1-a)*G solved for
9a and F) so outlines keep their real colour instead of a green fringe.
10
11Output goes to frontend/public:
12
13 logo.png 512 px, transparent, for pages and Open Graph
14 logo-128.png the nav and auth pages (crisp up to 3x at ~40 px)
15 icon-192.png web app manifest sizes
16 apple-touch-icon.png 180 px on the site's dark surface (iOS ignores alpha)
17 favicon.ico 16, 32 and 48 px
18 favicon-32.png modern browsers
19
20 python3 -I scripts/gen-brand.py
21"""
22
23import sys
24from pathlib import Path
25
26import numpy as np
27from PIL import Image
28
29ROOT = Path(__file__).resolve().parent.parent
30BRAND = ROOT / "brand"
31OUT = ROOT / "frontend" / "public"
32SURFACE = (11, 15, 22) # --color-surface, dark theme
33
34
35def key_out_green(path: Path) -> Image.Image:
36 rgb = np.asarray(Image.open(path).convert("RGB")).astype(np.float64)
37 r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
38
39 # Background colour from the corners, which are always background here.
40 edge = 60
41 corners = np.concatenate([rgb[:edge, :edge], rgb[:edge, -edge:], rgb[-edge:, :edge], rgb[-edge:, -edge:]]).reshape(-1, 3)
42 bg = np.median(corners, axis=0)
43 bg_green = bg[1] - max(bg[0], bg[2])
44
45 # Greenness: 0 for the neutral greys of the vault, bg_green for background.
46 greenness = g - np.maximum(r, b)
47 lo, hi = 0.12 * bg_green, 0.88 * bg_green
48 alpha = np.clip((hi - greenness) / (hi - lo), 0.0, 1.0)
49
50 # Unmix the background out of partially covered pixels.
51 a = alpha[..., None]
52 fg = np.where(a > 0.02, (rgb - (1.0 - a) * bg) / np.maximum(a, 1e-3), 0.0)
53 fg = np.clip(fg, 0, 255)
54 # The artwork is neutral greys and gold (red above green); remove any green
55 # spill left in the edge colours.
56 spill_cap = np.maximum(fg[..., 0], fg[..., 2]) + 3
57 fg[..., 1] = np.minimum(fg[..., 1], spill_cap)
58
59 rgba = np.dstack([fg, alpha * 255]).round().astype(np.uint8)
60 print(f"keyed out background {bg.round().astype(int).tolist()}")
61 return Image.fromarray(rgba, "RGBA")
62
63
64def load_transparent(path: Path) -> Image.Image:
65 rgba = np.asarray(Image.open(path).convert("RGBA")).copy()
66 alpha = rgba[..., 3]
67 # Background removers leave the body at 250-254; make it solid.
68 alpha[alpha >= 245] = 255
69 rgba[..., 3] = alpha
70 rgba[alpha == 0, :3] = 0
71 print(f"transparent source, {int((alpha == 255).sum())} opaque pixels")
72 return Image.fromarray(rgba, "RGBA")
73
74
75def squared(image: Image.Image) -> Image.Image:
76 """Crops to the ink and squares it up with transparent padding."""
77 alpha = np.asarray(image)[..., 3] / 255.0
78 ys, xs = np.where(alpha > 0.5)
79 x0, x1, y0, y1 = xs.min(), xs.max() + 1, ys.min(), ys.max() + 1
80 image = image.crop((x0, y0, x1, y1))
81 side = max(image.size)
82 pad = round(side * 0.02)
83 square = Image.new("RGBA", (side + 2 * pad, side + 2 * pad), (0, 0, 0, 0))
84 square.paste(image, ((square.width - image.width) // 2, (square.height - image.height) // 2), image)
85 print(f"artwork {x1 - x0}x{y1 - y0}, square {square.width}px")
86 return square
87
88
89def despill(image: Image.Image) -> Image.Image:
90 """Caps green at the larger of red and blue. Neutral greys and gold
91 (red above green) are untouched; only spill and resampling tint goes."""
92 px = np.asarray(image).copy()
93 cap = np.maximum(px[..., 0], px[..., 2]).astype(np.int16) + 3
94 px[..., 1] = np.minimum(px[..., 1], np.clip(cap, 0, 255)).astype(np.uint8)
95 return Image.fromarray(px, image.mode)
96
97
98def resized(image: Image.Image, size: int) -> Image.Image:
99 # Lanczos overshoots on the faint edge pixels beside the gold rims and
100 # tints them olive, so the spill cap runs again after every resize.
101 return despill(image.resize((size, size), Image.Resampling.LANCZOS))
102
103
104def load_master() -> Image.Image:
105 png, jpg = BRAND / "logo-source.png", BRAND / "logo-source.jpg"
106 if png.exists():
107 image = Image.open(png)
108 if image.mode in ("RGBA", "LA") and np.asarray(image.convert("RGBA"))[0, 0, 3] == 0:
109 return squared(load_transparent(png))
110 return squared(key_out_green(png))
111 return squared(key_out_green(jpg))
112
113
114def main() -> int:
115 master = load_master()
116 OUT.mkdir(parents=True, exist_ok=True)
117 resized(master, 512).save(OUT / "logo.png", optimize=True)
118 resized(master, 128).save(OUT / "logo-128.png", optimize=True)
119 resized(master, 192).save(OUT / "icon-192.png", optimize=True)
120 resized(master, 32).save(OUT / "favicon-32.png", optimize=True)
121
122 touch = Image.new("RGBA", (180, 180), SURFACE + (255,))
123 inner = resized(master, 152)
124 touch.paste(inner, (14, 14), inner)
125 touch.convert("RGB").save(OUT / "apple-touch-icon.png", optimize=True)
126
127 # Each favicon size is resized (and despilled) here rather than by the ICO writer.
128 icons = [resized(master, n) for n in (48, 32, 16)]
129 icons[0].save(OUT / "favicon.ico", sizes=[(48, 48), (32, 32), (16, 16)], append_images=icons[1:])
130 for name in ["logo.png", "logo-128.png", "icon-192.png", "favicon-32.png", "apple-touch-icon.png", "favicon.ico"]:
131 print(f" {name:22} {(OUT / name).stat().st_size:>7} bytes")
132 return 0
133
134
135if __name__ == "__main__":
136 sys.exit(main())