Files
fenix8v3-watchface/tools/gen_icons.py
ericwyuan 4d96ab0912 Update watch face: modular refactor + OWM/Solar/background + codegen tools
- Split render into FieldTable/Fields/Icons/Layout/Settings modules
- Add Fenix8V3Background, Owm (weather), Solar modules
- Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg)
- Tune README/manifest/strings/settings
2026-09-10 07:10:55 +08:00

203 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""把设计稿的图标路径光栅化成可染色的位图资源。
design/*.svg 里的日出和天气图标是描边出来的轮廓,最细的特征只有 23 个设计
像素宽。用圆和线段去重画它们在 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'<path id="%s" d="([^"]+)"' % pid, svg).group(1)
out = []
for chunk in d.split("Z"):
pts = [(float(x), float(y))
for x, y in re.findall(r"(-?\d+\.\d+),(-?\d+\.\d+)", chunk)]
if len(pts) >= 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'<g id="ring-top-right">(.*?)</g>', svg, re.S).group(1))
bot = re.findall(r'fill="(#[0-9A-Fa-f]{6})"',
re.search(r'<g id="ring-bottom-left">(.*?)</g>', 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('<drawables>\n'
' <bitmap id="LauncherIcon" filename="launcher_icon.png"/>\n'
'</drawables>\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("<drawables>\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(' <bitmap id="Icon%s" filename="%s.png" '
'packingFormat="png"/>\n' % (n.capitalize(), n))
f.write("</drawables>\n")
print("wrote %s (%dx%d, %s edges)"
% (outdir, size, size, "soft" if blend else "hard"))
if __name__ == "__main__":
main()