1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#!/usr/bin/env python3
"""Generate the Android launcher icons from the iOS asset catalogue.
Run after changing SOURCE or the framing constants:
python3 tools/generate_icons.py
The icon is a photograph, not a logo mark, which is what makes this a script
rather than a one-off export. Android masks the 108dp adaptive layer down to a
72dp safe zone, so the launcher only ever shows the centre ~67% of it. Framing
is therefore a deliberate choice: CROP_SIDE and CROP_TOP pick which square of
the source ends up on the canvas, and everything visible follows from that.
"""
import os
from PIL import Image, ImageDraw
SOURCE = os.path.expanduser(
"~/code/grain-ios/Grain/Assets.xcassets/AppIcon.appiconset/1024.png"
)
RES = "app/src/main/res"
STORE = "store"
# Square taken from the source, in source pixels. Smaller than the full 1024
# and pinned to the top so the ridge line, treeline and dusk sky all survive
# the mask — a centred full-width crop loses the mountains entirely.
CROP_SIDE = 800
CROP_TOP = 0
# The adaptive mask shows 72 of 108dp.
VISIBLE_FRACTION = 72 / 108
# Launcher icon is 48dp; adaptive layers are 108dp.
DENSITIES = {"mdpi": 1, "hdpi": 1.5, "xhdpi": 2, "xxhdpi": 3, "xxxhdpi": 4}
def main() -> None:
src = Image.open(SOURCE).convert("RGB")
width = src.size[0]
left = (width - CROP_SIDE) // 2
canvas_crop = src.crop(
(left, CROP_TOP, left + CROP_SIDE, CROP_TOP + CROP_SIDE)
)
# What the launcher actually shows once the mask is applied. The legacy and
# Play Store icons use this same framing so every surface agrees.
inset = round(CROP_SIDE * (1 - VISIBLE_FRACTION) / 2)
visible_crop = canvas_crop.crop(
(inset, inset, CROP_SIDE - inset, CROP_SIDE - inset)
)
for bucket, scale in DENSITIES.items():
directory = f"{RES}/mipmap-{bucket}"
os.makedirs(directory, exist_ok=True)
legacy_px = round(48 * scale)
adaptive_px = round(108 * scale)
square = visible_crop.resize((legacy_px, legacy_px), Image.LANCZOS)
square.save(f"{directory}/ic_launcher.png", optimize=True)
circular(square).save(f"{directory}/ic_launcher_round.png", optimize=True)
canvas_crop.resize((adaptive_px, adaptive_px), Image.LANCZOS).save(
f"{directory}/ic_launcher_background.png", optimize=True
)
print(f"{bucket}: legacy {legacy_px}px, adaptive layer {adaptive_px}px")
os.makedirs(STORE, exist_ok=True)
visible_crop.resize((512, 512), Image.LANCZOS).save(
f"{STORE}/play-store-icon-512.png", optimize=True
)
print("store/play-store-icon-512.png: 512px")
def circular(image: Image.Image) -> Image.Image:
"""Circular alpha mask, supersampled so the edge doesn't stair-step."""
size = image.size[0]
mask = Image.new("L", (size * 4, size * 4), 0)
ImageDraw.Draw(mask).ellipse((0, 0, size * 4 - 1, size * 4 - 1), fill=255)
out = image.convert("RGBA")
out.putalpha(mask.resize(image.size, Image.LANCZOS))
return out
if __name__ == "__main__":
main()