~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
85 lines · 3.0 KB Raw History
#!/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()