#!/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()