#!/usr/bin/env python3 """把设计稿的图标路径光栅化成可染色的位图资源。 design/*.svg 里的日出和天气图标是描边出来的轮廓,最细的特征只有 2–3 个设计 像素宽。用圆和线段去重画它们在 454px 上还行,到 280px 就彻底糊掉 —— 那个尺寸 下这些细节不足一个物理像素。 所以改成:把真实路径按每种屏宽各渲染一次,运行时用 drawBitmap2(:tintColor) 染上主题色。这样每台设备拿到的都是为它的像素网格 专门渲染的图。 python3 tools/gen_icons.py 产出 resources-icons-<屏宽>/drawables/{sunrise,sunset,weather}.png 以及配套的 drawables.xml;另外产出 resources-launcher-<尺寸>/ 下各机型要求尺寸的启动图标。 ⚠️ 两个要点: 1. drawables.xml 里必须写 packingFormat="png"。否则资源编译器会把图片压成 调色板格式,而 :tintColor 拒绝调色板化的源,运行时直接抛异常。 2. MIP 屏没有 alpha 混合,所以那几个尺寸的覆盖率蒙版会被阈值化成硬边 —— 软边在 64 色调色板下反而更脏。 """ import os import re HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) SRC = os.path.join(ROOT, "design", "watchface-01-ember.svg") try: from PIL import Image except ImportError: raise SystemExit("this generator needs Pillow: python3 -m pip install pillow") # The three icons the comps actually contain, and the design-space centre each # one is drawn around. A common 32x32 design box keeps blitting trivial. BOX = 32.0 ICONS = { "sunrise": ("sunrise-icon", 156.32, 89.5, False), "sunset": ("sunrise-icon", 156.32, 89.5, True), # same art, chevron flipped "weather": ("weather-icon", 343.55, 89.5, False), } # screen width -> whether the panel can blend alpha (MIP cannot) WIDTHS = {240: False, 260: False, 280: False, 390: True, 416: True, 454: True} SS = 8 # supersampling factor def subpaths(svg, pid): d = re.search(r'= 3: out.append(pts) return out def flip_chevron(paths): """Mirror the horizon bar so its notch points up -- our sunset variant.""" out = [] for p in paths: ys = [q[1] for q in p] if min(ys) > 96.0: # the bar is the lowest subpath mid = (min(ys) + max(ys)) / 2.0 p = [(x, 2 * mid - y) for x, y in p] out.append(p) return out def scanline_evenodd(paths, size, ox, oy, scale): """Even-odd fill at supersampled resolution, returned as a coverage mask.""" n = size * SS cov = [bytearray(n) for _ in range(n)] edges = [] for p in paths: for i in range(len(p)): x0, y0 = p[i] x1, y1 = p[(i + 1) % len(p)] X0 = (x0 - ox) * scale * SS Y0 = (y0 - oy) * scale * SS X1 = (x1 - ox) * scale * SS Y1 = (y1 - oy) * scale * SS if Y0 != Y1: edges.append((Y0, Y1, X0, X1)) for row in range(n): yc = row + 0.5 xs = [] for Y0, Y1, X0, X1 in edges: if (Y0 <= yc < Y1) or (Y1 <= yc < Y0): xs.append(X0 + (yc - Y0) * (X1 - X0) / (Y1 - Y0)) if not xs: continue xs.sort() line = cov[row] for i in range(0, len(xs) - 1, 2): a = max(0, int(round(xs[i]))) b = min(n, int(round(xs[i + 1]))) for x in range(a, b): line[x] = 255 return cov def render(paths, size, cx, cy, blend): scale = size / BOX ox, oy = cx - BOX / 2, cy - BOX / 2 cov = scanline_evenodd(paths, size, ox, oy, scale) n = size * SS big = Image.frombytes("L", (n, n), b"".join(bytes(r) for r in cov)) small = big.resize((size, size), Image.LANCZOS) if not blend: small = small.point(lambda v: 255 if v >= 110 else 0) white = Image.new("RGB", (size, size), (255, 255, 255)) img = white.convert("RGBA") img.putalpha(small) return img # Launcher icon sizes the target devices ask for. LAUNCHER = [40, 56, 60, 65, 70] def launcher(size): """A miniature of the face: the gradient ring plus the date band.""" from PIL import ImageDraw ss = 8 n = size * ss img = Image.new("RGBA", (n, n), (0, 0, 0, 0)) d = ImageDraw.Draw(img) d.ellipse([0, 0, n - 1, n - 1], fill=(0, 0, 0, 255)) svg = open(SRC).read() top = re.findall(r'fill="(#[0-9A-Fa-f]{6})"', re.search(r'(.*?)', svg, re.S).group(1)) bot = re.findall(r'fill="(#[0-9A-Fa-f]{6})"', re.search(r'(.*?)', svg, re.S).group(1)) ro, ri = n * 0.495, n * 0.375 box_o = [n / 2 - ro, n / 2 - ro, n / 2 + ro, n / 2 + ro] # sweep the whole rim, mirroring the comp's two ramps into four quadrants for q, ramp in ((0, top), (1, top), (2, bot), (3, bot)): for i, c in enumerate(ramp): col = tuple(int(c[1 + 2 * j:3 + 2 * j], 16) for j in range(3)) + (255,) span = 90.0 / len(ramp) if q == 0: a0 = -90 + i * span elif q == 1: a0 = -90 - (i + 1) * span elif q == 2: a0 = 90 - (i + 1) * span else: a0 = 90 + i * span d.pieslice(box_o, a0, a0 + span, fill=col) d.ellipse([n / 2 - ri, n / 2 - ri, n / 2 + ri, n / 2 + ri], fill=(0, 0, 0, 255)) band = tuple(int("FEAA00"[2 * j:2 * j + 2], 16) for j in range(3)) + (255,) d.rectangle([0, n * 0.44, n, n * 0.60], fill=band) mask = Image.new("L", (n, n), 0) ImageDraw.Draw(mask).ellipse([0, 0, n - 1, n - 1], fill=255) img.putalpha(mask) return img.resize((size, size), Image.LANCZOS) def main(): svg = open(SRC).read() for size in LAUNCHER: outdir = os.path.join(ROOT, "resources-launcher-%d" % size, "drawables") os.makedirs(outdir, exist_ok=True) launcher(size).save(os.path.join(outdir, "launcher_icon.png")) with open(os.path.join(outdir, "drawables.xml"), "w") as f: f.write('\n' ' \n' '\n') print("wrote %s (%dx%d)" % (outdir, size, size)) cache = {} for width, blend in sorted(WIDTHS.items()): size = int(round(BOX * width / 500.0)) outdir = os.path.join(ROOT, "resources-icons-%d" % width, "drawables") os.makedirs(outdir, exist_ok=True) names = [] for name, (pid, cx, cy, flip) in ICONS.items(): key = (pid, flip) if key not in cache: p = subpaths(svg, pid) cache[key] = flip_chevron(p) if flip else p img = render(cache[key], size, cx, cy, blend) img.save(os.path.join(outdir, "%s.png" % name)) names.append(name) with open(os.path.join(outdir, "drawables.xml"), "w") as f: f.write("\n") for n in names: # packingFormat="png" keeps the alpha channel instead of # collapsing the image to a palette; drawBitmap2(:tintColor) # refuses a palettised source. f.write(' \n' % (n.capitalize(), n)) f.write("\n") print("wrote %s (%dx%d, %s edges)" % (outdir, size, size, "soft" if blend else "hard")) if __name__ == "__main__": main()