Files
fenix8v3-watchface/tools/gen_icons.py
ericwyuan 22340b09fa 机型覆盖 17 → 55 款,补 LICENSE 与 CHANGELOG
机型
  新增 38 款圆屏 + CIQ ≥ 4.2 的机型:fenix 7 Pro 全系、Venu 2/3S、
  Approach S50/S70、Descent G2/Mk3、D2 系列、Instinct 3 AMOLED、
  FR255S/265S/570/955 等。其中 34 款的屏宽已有现成图标资源。

  机型列表和 monkey.jungle 改由 tools/gen_devices.py 扫描本机 SDK 生成,
  筛选条件是「支持 watchFace + 圆屏 + CIQ ≥ 4.2」(Complications 要 4.2)。
  55 条资源路径不再手写。

  新增 218px 与 360px 两档图标。218px 上图标只有 14×14,设计稿里太阳与云
  之间那道 1.7 设计像素的暗缝连一个物理像素都占不到,光栅化出来是一坨没有
  结构的橙块 —— 该尺寸改用按目标像素直接摆的简化标记。

常亮模式:结论是不改
  为了压所谓的 10% 点亮率,一度把进度环也砍掉了。换成平均亮度(AMOLED 功耗
  的合理代理)重新量之后发现:普通模式 14.1%,常亮 2.9%,本来就只有 1/5;
  砍掉环只从 3.01% 降到 2.91%。0.1 个百分点换掉一个可见特征不划算,环留着。
  两种度量的差异和这个决定都写进了代码注释和 README。

其它
  - LICENSE(MIT)。只覆盖代码 —— design/ 下的设计稿版权归第三方,
    发布前需取得原作者授权或替换视觉,文件内已注明。
  - UserProfile.getProfile() 加异常保护(这个 API 家族已经坑过三次)
  - CHANGELOG.md,版本号 1.0.0 → 1.1.0

验证:55 款全部编译通过(分三批 18+18+19);.iq 上架包可正常导出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:27:54 +08:00

250 lines
10 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),
}
# 屏宽 -> 该尺寸下的屏幕能否做 alpha 混合MIP 屏不能)。
# 这张表和启动图标尺寸表都由 tools/gen_devices.py 扫描出的机型集合决定,
# 改完设备列表要跟着更新这里 —— 少一个尺寸就会有机型编译不过。
WIDTHS = {218: False, 240: False, 260: False, 280: False,
360: True, 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
# 各机型要求的启动图标尺寸Garmin 每款表都有自己的规定值,不能用缩放糊弄)。
LAUNCHER = [38, 40, 54, 56, 60, 61, 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 tiny_weather(size):
"""给 16px 以下的屏幕专画的天气标记。
设计稿的天气图标是「太阳被云挡住一半」,靠一道暗缝把两者分开。那道缝在
500 画布上只有 1.7px,到 218px 屏(图标 14×14连一个物理像素都占不到
光栅化出来就是一坨没有结构的橙块。
所以这个尺寸不再走设计稿路径,改用按目标像素直接摆的简化标记:
右上一个太阳圆盘,左下一朵云,中间强制留 1 个像素的空隙。
形不如原图,但至少还认得出是「天气」。
"""
from PIL import ImageDraw
ss = 8
n = size * ss
img = Image.new("L", (n, n), 0)
d = ImageDraw.Draw(img)
u = n / 14.0 # 以 14 格为基准摆放,任何尺寸都等比
def disc(cx, cy, r, fill):
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill)
# 太阳 + 四道光芒(光芒画成小方块,比线段在低分辨率下更容易留下来)
disc(9.2 * u, 4.6 * u, 2.7 * u, 255)
for dx, dy in ((0, -4.4), (4.3, 0), (-3.2, -3.2), (3.2, -3.2)):
d.rectangle([9.2 * u + dx * u - 0.55 * u, 4.6 * u + dy * u - 0.55 * u,
9.2 * u + dx * u + 0.55 * u, 4.6 * u + dy * u + 0.55 * u],
fill=255)
# 先用黑色把云的轮廓“撑大”一圈,留出与太阳之间的缝,再画回本体
for grow, fill in ((1.0 * u, 0), (0.0, 255)):
disc(5.0 * u, 8.6 * u, 3.3 * u + grow, fill)
disc(8.6 * u, 9.6 * u, 2.4 * u + grow, fill)
d.rectangle([1.7 * u - grow, 8.8 * u - grow, 11.0 * u + grow, 12.2 * u],
fill=fill)
small = img.resize((size, size), Image.LANCZOS)
small = small.point(lambda v: 255 if v >= 110 else 0)
out = Image.new("RGB", (size, size), (255, 255, 255)).convert("RGBA")
out.putalpha(small)
return out
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
# 天气图标在极小尺寸下用专门的简化画法,日出/日落还撑得住。
if name == "weather" and size < 16:
img = tiny_weather(size)
else:
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()