Files
fuelish/android/scripts/gen_app_icons.py
T
ml 516f5d3b7d
build / build (push) Failing after 14s
Add HTTP API server, Android app, and CI build/publish pipeline
- docker-compose.yml + tooling-container/src/server.py: serve
  preprocess/OCR/extract over an async job API (POST /jobs, GET
  /jobs/{id}) instead of manual host-script runs per receipt
- android/: Kotlin/Compose app - capture a receipt photo, poll job
  progress, review/edit extracted fields, save locally (Room), export
  to CSV/ODS
- .gitea/workflows/build.yml: build the debug APK and publish it to
  dl.ladkau.de on push
- build-image.sh: also pull ollama/ollama so all pipeline images are
  ready after one script
- add project logo (also used for the app's launcher icon/splash)
2026-09-29 12:58:58 +02:00

116 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""Generates the Android launcher icon and splash screen art from
../../logo.png (the repo's brand mark + "fuelish" wordmark).
Only the graphic mark (not the wordmark) works as a launcher/splash icon,
so this first isolates it: crop the upper portion of the source image
(where the mark lives) and auto-crop that to its actual non-transparent
bounding box, rather than relying on any hardcoded pixel offsets that
would break if logo.png is ever redrawn at a different resolution.
Produces:
- mipmap-<density>/ic_launcher_foreground.png - the mark centered, scaled
to the adaptive-icon safe zone (66% of a 108dp canvas), transparent
background - paired at runtime with @color/ic_launcher_background (see
mipmap-anydpi-v26/ic_launcher.xml, committed - a static reference, not
generated) for API 26+.
- mipmap-<density>/ic_launcher.png / ic_launcher_round.png - the same
mark flattened onto ic_launcher_background, for API <26 devices that
can't use the adaptive-icon XML at all.
- drawable-nodpi/splash_icon.png - the mark on a transparent background,
referenced by Theme.Fuelish.Splash's windowSplashScreenAnimatedIcon
(see values/themes.xml).
Generated output, not committed (see the repo's root .gitignore's
/android/app/src/main/res/{mipmap-*,drawable-nodpi} entries) - regenerate
by running this script directly or via Gradle's generateAppIcons task
(wired into app's preBuild, see ../app/build.gradle.kts). Requires
Pillow.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOGO_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "logo.png"))
RES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res"))
BACKGROUND_RGBA = (255, 255, 255, 255) # matches @color/ic_launcher_background
# (density qualifier, adaptive-icon canvas px, legacy launcher px)
DENSITIES = [
("mdpi", 108, 48),
("hdpi", 162, 72),
("xhdpi", 216, 96),
("xxhdpi", 324, 144),
("xxxhdpi", 432, 192),
]
# Adaptive icons render only the inner ~66% "safe zone" reliably across
# launchers that apply their own mask shape.
SAFE_ZONE_RATIO = 0.66
# Fraction of logo.png's height (from the top) that contains the graphic
# mark, before the "fuelish" wordmark begins.
MARK_REGION_HEIGHT_RATIO = 0.60
SPLASH_CANVAS_PX = 480
SPLASH_ICON_RATIO = 0.75
def extract_mark(logo: Image.Image) -> Image.Image:
width, height = logo.size
region = logo.crop((0, 0, width, round(height * MARK_REGION_HEIGHT_RATIO)))
bbox = region.getchannel("A").getbbox()
if bbox is None:
raise ValueError(f"{LOGO_PATH}: no non-transparent pixels found in the top "
f"{MARK_REGION_HEIGHT_RATIO:.0%} of the image")
mark = region.crop(bbox)
# Pad to square so downstream scaling doesn't distort the aspect ratio.
side = max(mark.width, mark.height)
square = Image.new("RGBA", (side, side), (0, 0, 0, 0))
square.paste(mark, ((side - mark.width) // 2, (side - mark.height) // 2), mark)
return square
def resized(image: Image.Image, target_px: int) -> Image.Image:
ratio = target_px / max(image.width, image.height)
size = (round(image.width * ratio), round(image.height * ratio))
return image.resize(size, Image.LANCZOS)
def centered_on(canvas_px: int, content: Image.Image, background: tuple) -> Image.Image:
canvas = Image.new("RGBA", (canvas_px, canvas_px), background)
offset = ((canvas_px - content.width) // 2, (canvas_px - content.height) // 2)
canvas.paste(content, offset, content)
return canvas
def main():
logo = Image.open(LOGO_PATH).convert("RGBA")
mark = extract_mark(logo)
for density, canvas_px, legacy_px in DENSITIES:
mipmap_dir = os.path.join(RES_DIR, f"mipmap-{density}")
os.makedirs(mipmap_dir, exist_ok=True)
foreground = centered_on(canvas_px, resized(mark, round(canvas_px * SAFE_ZONE_RATIO)), (0, 0, 0, 0))
foreground.save(os.path.join(mipmap_dir, "ic_launcher_foreground.png"))
legacy = centered_on(legacy_px, resized(mark, round(legacy_px * SAFE_ZONE_RATIO)), BACKGROUND_RGBA)
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher.png"))
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher_round.png"))
drawable_dir = os.path.join(RES_DIR, "drawable-nodpi")
os.makedirs(drawable_dir, exist_ok=True)
splash = centered_on(SPLASH_CANVAS_PX, resized(mark, round(SPLASH_CANVAS_PX * SPLASH_ICON_RATIO)), (0, 0, 0, 0))
splash.save(os.path.join(drawable_dir, "splash_icon.png"))
print(f"wrote launcher icons for {len(DENSITIES)} densities + splash icon to {RES_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()