159 lines
6.6 KiB
Python
159 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Builds the app's logo resources from the source picture design/logo/sleepyowl.png (owl over the "SleepyOwl"
|
||
wordmark on a flat navy background, 1254×1254):
|
||
|
||
app/src/main/res/drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on navy
|
||
app/src/main/res/drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is
|
||
@color/ic_launcher_background
|
||
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
|
||
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
|
||
|
||
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
|
||
classpath.
|
||
|
||
The navy is keyed out ("color to alpha"): the glow stays as translucent amber, so the owl and the wordmark sit on the
|
||
app's own dark background without a square. The owl's body is made opaque so it does not take the colour of what is
|
||
behind it. The in-app pictures are made at the TV's density (xhdpi, 2 px per dp) and at the size they are drawn
|
||
(see ui/components/Brand.kt in ui:shared), so they are not resampled on the TV.
|
||
|
||
Usage:
|
||
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--root REPO] [--preview DIR]
|
||
"""
|
||
import argparse
|
||
import os
|
||
|
||
import numpy as np
|
||
from PIL import Image
|
||
from scipy import ndimage
|
||
|
||
BG = np.array([5, 9, 39]) / 255 # the logo's background
|
||
DEAD_ZONE = 7 / 255 # background noise and vignette, keyed out as well
|
||
OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882
|
||
WORD_ROWS = (870, 1080)
|
||
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
|
||
APP_RES = "app/src/main/res/"
|
||
SHARED_RES = "ui/shared/src/androidMain/res/"
|
||
|
||
DP = 2 # xhdpi
|
||
|
||
|
||
def key_out(rgb):
|
||
"""Smallest alpha with rgb = alpha·F + (1 − alpha)·BG, F in [0, 1]; returns straight (not premultiplied) F and alpha."""
|
||
diff = rgb - BG
|
||
up = np.clip(diff - DEAD_ZONE, 0, None) / (1 - BG - DEAD_ZONE)
|
||
down = np.clip(-diff - DEAD_ZONE, 0, None) / (BG - DEAD_ZONE)
|
||
alpha = np.clip(np.maximum(up, down).max(2), 0, 1)
|
||
return alpha
|
||
|
||
|
||
def colour(rgb, alpha):
|
||
a = alpha[..., None]
|
||
return np.clip(np.where(a > 1e-4, (rgb - (1 - a) * BG) / np.maximum(a, 1e-4), 0), 0, 1)
|
||
|
||
|
||
def solid_body(alpha):
|
||
"""The owl's silhouette (holes filled, edge pulled in a little and softened) — fully opaque."""
|
||
mask = ndimage.binary_fill_holes(alpha > 0.6)
|
||
mask = ndimage.binary_opening(mask, iterations=2)
|
||
mask = ndimage.binary_erosion(mask, iterations=3)
|
||
return ndimage.gaussian_filter(mask.astype(float), 1.2)
|
||
|
||
|
||
def bbox(alpha, threshold=0.03):
|
||
ys, xs = np.where(alpha > threshold)
|
||
return xs.min(), ys.min(), xs.max() + 1, ys.max() + 1
|
||
|
||
|
||
def to_image(rgb, alpha):
|
||
return Image.fromarray((np.dstack([rgb, alpha]) * 255 + 0.5).astype(np.uint8), "RGBA")
|
||
|
||
|
||
def fit(img, height):
|
||
"""Scales [img] to [height] px: premultiplied while resampling, so the transparent edge does not darken."""
|
||
w = round(img.width * height / img.height)
|
||
return img.convert("RGBa").resize((w, height), Image.LANCZOS).convert("RGBA")
|
||
|
||
|
||
def on_navy(size, *placed):
|
||
canvas = Image.new("RGBA", size, tuple(int(c * 255 + 0.5) for c in BG) + (255,))
|
||
for img, xy in placed:
|
||
canvas.alpha_composite(img, xy)
|
||
return canvas.convert("RGB")
|
||
|
||
|
||
def build(src):
|
||
rgb = np.asarray(Image.open(src).convert("RGB")).astype(float) / 255
|
||
alpha = key_out(rgb)
|
||
|
||
top, bottom = OWL_ROWS
|
||
owl_alpha = alpha[top:bottom].copy()
|
||
owl_alpha = np.maximum(owl_alpha, solid_body(owl_alpha))
|
||
owl = to_image(colour(rgb[top:bottom], owl_alpha), owl_alpha)
|
||
# Square around the owl with its near glow; the far, faint glow is cut.
|
||
x0, y0, x1, y1 = bbox(owl_alpha, 0.08)
|
||
side = max(x1 - x0, y1 - y0) + 16
|
||
cx, cy = (x0 + x1) // 2, (y0 + y1) // 2
|
||
owl = owl.crop((cx - side // 2, cy - side // 2, cx - side // 2 + side, cy - side // 2 + side))
|
||
# Farthest point of the body from the centre: the icon keeps it inside the adaptive icon's 66 dp safe circle.
|
||
body = np.asarray(owl)[..., 3] > 200
|
||
ys, xs = np.nonzero(body)
|
||
body_radius = np.sqrt((xs - side / 2) ** 2 + (ys - side / 2) ** 2).max()
|
||
|
||
top, bottom = WORD_ROWS
|
||
word_alpha = alpha[top:bottom]
|
||
word = to_image(colour(rgb[top:bottom], word_alpha), word_alpha)
|
||
x0, y0, x1, y1 = bbox(word_alpha)
|
||
word = word.crop((x0 - 2, y0 - 2, x1 + 2, y1 + 2))
|
||
|
||
out = {}
|
||
|
||
# TV banner 320×180 dp: owl on the left, wordmark on the right.
|
||
bw, bh = 320 * DP, 180 * DP
|
||
owl_b = fit(owl, 130 * DP)
|
||
word_b = fit(word, 32 * DP)
|
||
gap = 6 * DP
|
||
x = (bw - owl_b.width - gap - word_b.width) // 2
|
||
out[APP_RES + "drawable-xhdpi/banner.png"] = on_navy(
|
||
(bw, bh),
|
||
(owl_b, (x, (bh - owl_b.height) // 2)),
|
||
(word_b, (x + owl_b.width + gap, (bh - word_b.height) // 2 + 2 * DP)),
|
||
)
|
||
|
||
# Adaptive icon foreground, 108 dp at xxxhdpi (4 px per dp); the body's radius is 31 dp.
|
||
size = 108 * 4
|
||
scale = 31 * 4 / body_radius
|
||
owl_i = fit(owl, round(owl.height * scale))
|
||
fg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
|
||
out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
|
||
|
||
# In-app marks, drawn at exactly these sizes (Brand.kt).
|
||
out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
|
||
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
|
||
return out
|
||
|
||
|
||
def main():
|
||
root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
p.add_argument("--src", default=os.path.join(root, "design", "logo", "sleepyowl.png"))
|
||
p.add_argument("--root", default=root, help="repository root the resource paths are relative to")
|
||
p.add_argument("--preview", help="also write the pictures over the app's background into this directory")
|
||
args = p.parse_args()
|
||
|
||
for path, img in build(args.src).items():
|
||
dst = os.path.join(args.root, path)
|
||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||
img.save(dst, optimize=True)
|
||
print(f"{dst} {img.width}×{img.height}")
|
||
if args.preview:
|
||
os.makedirs(args.preview, exist_ok=True)
|
||
bg = Image.new("RGBA", img.size, (0x0A, 0x0B, 0x10, 255))
|
||
bg.alpha_composite(img.convert("RGBA"))
|
||
bg.convert("RGB").save(os.path.join(args.preview, os.path.basename(path)))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|