Files
o62lte-customising-plan/s5e-icons/tools/s5e_payload.py
T

809 lines
39 KiB
Python
Raw Normal View History

2026-10-02 21:34:56 +08:00
# -*- coding: utf-8 -*-
"""S5 41mm (q63, OS4) launcher icons -> Xiaomi Watch S5 eSIM (p62lte, 480x480).
Target slots live in TWO partitions:
vela_app.bin (/dev/app) 34 launcher.bin
vela_health.bin (/dev/health) 15 launcher.bin
All 49 target slots are 144x144 ARGB8888 (82956 bytes) -- so the replacement is
ALWAYS exactly one slot long, which is what keeps the ROMFS absolute offsets valid.
Sizing convention (measured, not guessed):
the S5 eSIM stock art is inset in the 144 canvas (136x136 at (4,4) for almost
every slot, 138 at (3,3) for launcher_*, 144 full-bleed for a few), while the
OS4 source art is mostly full-bleed 144x144. So each source is cropped to its
painted bbox, scaled to the TARGET slot's own painted size and pasted at the
TARGET slot's origin. Sources that already match are copied without resampling
(bit-exact).
Outputs
watchface_s5e/payload/payload.bin MAGIC + concat(replace blobs)
watchface_s5e/payload/payload.lua per-record table for main.lua
watchface_s5e/payload_rollback/... same for the stock bytes
_out/s5e_from_os4/vela_app_os4.bin whole-image variants (route C)
_out/s5e_from_os4/vela_health_os4.bin
"""
import os, sys, json, struct, hashlib, shutil
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from romfs import Romfs, img_header
from lvgl_bin import decode_lvgl_bin
import iconpack as ip
from PIL import Image, ImageFilter
from paths import PROJ, INPUTS, WORK
NEW = os.path.join(INPUTS, 'target') # 目标固件分区(p62lte)
OLD = os.path.join(INPUTS, 'source') # 图标来源分区(q63 = S5 41mm)
WF = PROJ
OUT = os.path.join(WORK, 's5e_from_os4') # manifest + 整分区镜像(产物)
PART = [('app', 'vela_app.bin', '/dev/app'),
('health', 'vela_health.bin', '/dev/health')]
MAGIC_PAY = b'\x00S5E-PAYv1\x00\xa5\x5a\xc3\x3c' # 15 B, must stay unique in the .face
MAGIC_RB = b'\x00S5E-RBKv1\x00\x5a\xa5\x3c\xc3'
# 自制图覆盖:优先级高于固件来源(和 S4 项目同一套做法)。
# activities : 112x112 的红/绿/青同心环 = 用户要的「跑道」图标
# voice_aivs : 144x144 的「小爱同学」用户 PNG
OVERRIDE = {k: os.path.join(PROJ, 'assets', 'override', k + '.png')
for k in ('activities', 'voice_aivs')}
os.makedirs(OUT, exist_ok=True)
os.makedirs(os.path.join(WF, 'payload'), exist_ok=True)
os.makedirs(os.path.join(WF, 'payload_rollback'), exist_ok=True)
def ink_bbox(img, thr=16):
a = img.convert('RGBA').getchannel('A')
w, h = a.size
px = a.load()
x0, y0, x1, y1 = w, h, -1, -1
for y in range(h):
for x in range(w):
if px[x, y] > thr:
if x < x0: x0 = x
if y < y0: y0 = y
if x > x1: x1 = x
if y > y1: y1 = y
return None if x1 < 0 else (x0, y0, x1, y1)
def normalize(src_img, slot_bb, canvas):
"""crop to painted bbox -> scale to the slot's painted size -> paste at slot origin"""
cw, ch = canvas
if slot_bb is None:
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
sx0, sy0, sx1, sy1 = slot_bb
tw, th = sx1 - sx0 + 1, sy1 - sy0 + 1
nb = ink_bbox(src_img)
if nb is None:
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
if (nb[2] - nb[0] + 1, nb[3] - nb[1] + 1) == (tw, th) and (nb[0], nb[1]) == (sx0, sy0):
return src_img.convert('RGBA') # already identical geometry
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
if art.size != (tw, th):
up = tw > art.size[0] or th > art.size[1]
art = art.resize((tw, th), Image.LANCZOS)
if up:
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
canvas_img = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
canvas_img.paste(art, (sx0, sy0))
return canvas_img
def normalize_fit(src_img, target_bb, canvas):
"""等比缩放源实心区域到能放进 target_bb 的最大尺寸并居中(不拉伸)。"""
cw, ch = canvas
cv = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
if target_bb is None:
return cv
nb = ink_bbox(src_img)
if nb is None:
return cv
tw, th = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
s = min(tw / art.size[0], th / art.size[1])
nw, nh = max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))
if (nw, nh) != art.size:
up = s > 1.0
art = art.resize((nw, nh), Image.LANCZOS)
if up:
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
cv.paste(art, (target_bb[0] + (tw - nw) // 2, target_bb[1] + (th - nh) // 2))
return cv
# ------------------------------------------------------------------ load slots
slots = [] # ordered records
ROM_N = {}
for tag, fn, dev in PART:
r = Romfs(os.path.join(NEW, fn))
ROM_N[tag] = r
tg = ip.targets(r)
for name in sorted(tg):
ino = tg[name]
blob = r.read(ino)
h, img = decode_lvgl_bin(blob)
slots.append(dict(tag=tag, dev=dev, image=fn, name=name, ino=ino, blob=blob,
h=h, img=img, bb=ink_bbox(img) if img is not None else None))
src = {}
ROM_O = {}
for fn in ['vela_app.bin', 'vela_health.bin']:
r = Romfs(os.path.join(OLD, fn))
ROM_O['app' if fn == 'vela_app.bin' else 'health'] = r
for name, ino in ip.targets(r).items():
if name in src: continue
h, img = decode_lvgl_bin(r.read(ino))
src[name] = (h, img, r.read(ino))
print('target slots %d source icons %d' % (len(slots), len(src)))
rows, newblobs, stock_of, skipped = [], {}, {}, []
for s in slots:
h = s['h']
if h is None:
skipped.append((s['name'], 'target not decodable')); continue
if s['name'] not in src:
skipped.append((s['name'], 'no OS4 source')); continue
sh, simg, sblob = src[s['name']]
if simg is None:
skipped.append((s['name'], 'source not decodable')); continue
src_from = 'q63 固件 %s' % s['image']
ov = OVERRIDE.get(s['name'])
if ov and os.path.exists(ov):
# 自制图覆盖:裁到实心 bbox -> 缩放到目标槽位的画法(136x136)-> 居中粘回 (4,4)
simg = Image.open(ov).convert('RGBA')
sblob = b''
src_from = '自制图 %s (%dx%d)' % (os.path.basename(ov), simg.size[0], simg.size[1])
print(' [override] %-14s <- %s %dx%d' % (s['name'], os.path.basename(ov),
simg.size[0], simg.size[1]))
out_img = normalize(simg, s['bb'], (h['w'], h['h']))
blob = ip.encode(out_img, h['cf'], h['w'], h['h'], h['flags'])
if len(blob) != s['ino'].size:
skipped.append((s['name'], 'length %d != slot %d' % (len(blob), s['ino'].size))); continue
exact = (blob == sblob)
newblobs[s['name']] = blob
stock_of[s['name']] = s['blob']
rows.append(dict(name=s['name'], tag=s['tag'], dev=s['tag'], node=s['dev'], image=s['image'],
offset=s['ino'].data_off, length=s['ino'].size,
dim='%dx%d' % (h['w'], h['h']), cf=ip.FMT.get(h['cf'], hex(h['cf'])),
stock_ink=s['bb'], source_from=src_from,
identical_to_stock=exact,
sha256=hashlib.sha256(blob).hexdigest(),
rb_sha256=hashlib.sha256(s['blob']).hexdigest()))
print('%-24s %-7s %-12s %-14s %s' % ('name', 'part', 'offset', 'slot', 'stock ink -> new'))
for s in slots:
r = next((x for x in rows if x['name'] == s['name']), None)
if r:
print('%-24s %-7s 0x%08x %-8d %-14s' % (r['name'], r['tag'], r['offset'], r['length'],
str(r['stock_ink']) + (' (identical)' if r['identical_to_stock'] else '')))
for n, why in skipped:
print('%-24s SKIP %s' % (n, why))
print('\nreplace %d skip %d' % (len(rows), len(skipped)))
# ------------------------------------------------- 系统 UI 素材:从 S5 41mm(q63) 搬过来
# 只搬「独立字形图标」(锁屏 / 通知中心 / 设置 / 米家)。控制中心的开关磁贴**故意没搬**:
# q63 的控制中心是白色主题(白盘 + 深色字形,运行时着色),S5 eSIM 是蓝色主题(预着色成品),
# 只搬那 4 张会变成「白盘混在蓝盘里」;要搬就得整套 ~40 张一起搬。
SYSICONS = [
('app', 'system/icon/lock_icon.bin', '锁屏上的锁'),
('app', 'common/icon/system_lock.bin', '状态栏系统锁'),
('app', 'common/icon/lock.bin', '通用锁'),
('app', 'system/icon/unlock.bin', '解锁提示页'),
('app', 'common/icon/quiet_mode.bin', '免打扰/静音小图标'),
('app', 'common/icon/unread_msg.bin', '未读消息红点'),
('app', 'notifications/default.bin', '通知默认大图标'),
('app', 'notifications/default_small.bin', '通知默认小图标'),
('app', 'notifications/no_message.bin', '通知中心空状态'),
('app', 'settings/icon/notify.bin', '设置-通知'),
('app', 'settings/disturb/confirm.bin', '免打扰确认按钮'),
('app', 'esimsms/icon/esim_sms_reminder.bin', '短信提醒'),
('app', 'esimsms/icon/sms_no_read.bin', '未读短信'),
('app', 'esimsms/icon/sms_reminder_watch.bin', '手表短信提醒'),
('app', 'phone/icon/send_sms_new.bin', '新建短信'),
('app', 'mijia/enable_donot_disturb.bin', '米家 免打扰开'),
('app', 'mijia/disable_donot_disturb.bin', '米家 免打扰关'),
('health', 'activities/icon/notification_stand.bin', '久坐提醒'),
('health', 'activities/icon/notification_vigor.bin', '活力提醒'),
# 压力 App 的小表情(4 档情绪 × 32/48/96,96 那三张两边本来就一样,会被自动跳过)
('health', 'pressure/icon/presure_relax.bin', '压力表情-放松 48'),
('health', 'pressure/icon/presure_mild.bin', '压力表情-轻度 48'),
('health', 'pressure/icon/presure_mid.bin', '压力表情-中度 48'),
('health', 'pressure/icon/presure_severe.bin', '压力表情-重度 48'),
('health', 'pressure/icon/presure_relax32.bin', '压力表情-放松 32'),
('health', 'pressure/icon/presure_mild32.bin', '压力表情-轻度 32'),
('health', 'pressure/icon/presure_mid32.bin', '压力表情-中度 32'),
('health', 'pressure/icon/presure_severe32.bin', '压力表情-重度 32'),
('health', 'pressure/icon/presure_relax96.bin', '压力表情-放松 96'),
('health', 'pressure/icon/presure_mild96.bin', '压力表情-轻度 96'),
('health', 'pressure/icon/presure_mid96.bin', '压力表情-中度 96'),
('health', 'pressure/icon/presure_severe96.bin', '压力表情-重度 96'),
('health', 'pressure/icon/presure_icon.bin', '压力 App 小图标'),
('health', 'pressure/icon/press_grade1.bin', '压力等级徽标 1'),
('health', 'pressure/icon/press_grade2.bin', '压力等级徽标 2'),
('health', 'pressure/icon/press_grade3.bin', '压力等级徽标 3'),
('health', 'pressure/icon/press_grade4.bin', '压力等级徽标 4'),
('health', 'pressure/icon/pressure_eomji_bg.bin', '表情底版'),
('health', 'pressure/icon/pressure_eomji_widbg.bin', '表情底版(小)'),
# 没有搬(体积大 / 不是表情):pressure_bg、success_bg、success_arc、
# press_widget_bg*、press_chart_bg、pressure_widget_pre_img*、reminder、measure/*
]
# 整目录一起搬(q63 有同名文件的才搬):控制中心的开关磁贴
SYSICONS_DIRS = ['control_center/icon/']
def find_inode(romfs, path):
for p, i in romfs.entries():
if p == path and i.type != 1:
return i
return None
print('\n--- 系统 UI 素材(锁屏 / 勿扰 / 通知 / 控制中心):q63 -> S5 eSIM ---')
_jobs = [(d, p, n) for d, p, n in SYSICONS]
_seen = {p for _, p, _ in _jobs}
for _dev in ROM_N:
for _p, _i in ROM_N[_dev].entries():
if _i.type == 1 or _p in _seen:
continue
if any(_p.startswith(_d) for _d in SYSICONS_DIRS):
_jobs.append((_dev, _p, '控制中心磁贴'))
_seen.add(_p)
for dev, path, note in _jobs:
ti = find_inode(ROM_N[dev], path)
oi = find_inode(ROM_O[dev], path)
if ti is None:
skipped.append((path, '目标固件没有这个文件')); continue
if oi is None:
skipped.append((path, 'q63 没有这个文件')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[dev].read(oi)
if src_blob == tgt_blob:
print(' %-52s 两边字节完全相同,跳过' % path)
continue
th = img_header(tgt_blob)
sh = img_header(src_blob)
if len(src_blob) == len(tgt_blob):
new, how = src_blob, 'copy(等长)'
elif th and sh and th['cf'] == sh['cf']:
timg = decode_lvgl_bin(tgt_blob)[1]
simg = decode_lvgl_bin(src_blob)[1]
if timg is None or simg is None:
skipped.append((path, '无法解码')); continue
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
out = normalize_fit(simg, ink_bbox(timg), (th['w'], th['h']))
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
how = 'rescale %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
else:
skipped.append((path, '长度/格式不支持')); continue
if len(new) != len(tgt_blob):
skipped.append((path, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
name = 'sys:' + path
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=path, note=note, how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=path,
offset=ti.data_off, length=ti.size,
dim=('%dx%d' % (th['w'], th['h'])) if th else '?',
cf=ip.FMT.get(th['cf'], hex(th['cf'])) if th else '?',
stock_ink=ink_bbox(decode_lvgl_bin(tgt_blob)[1]) if th and decode_lvgl_bin(tgt_blob)[1] else None,
source_from='q63 固件 / ' + path, identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
print(' %-52s %-22s %s' % (path, how, note))
# ------------------------------------------------- 对讲机素材(用户提供的整套 PNG)
# walkie_talkie/ 下的槽位几乎**全是 I8+RLE 压缩**的:不能像图标那样直接编码(长度会变),
# 必须走 ip.encode_into_slot():量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize,
# 这样字节数与原槽位完全相等,ROMFS 后面所有偏移一个字节都不动。
WALKIE_SRC = os.path.join(PROJ, 'assets', 'walkie_talkie')
# 对讲机素材:**默认不打包**(用户 2026-10-02 决定不改)。ICON_WALKIE=1 可打开。
DO_WALKIE = os.environ.get('ICON_WALKIE') == '1'
print('\n--- 对讲机素材 walkie_talkie(用户 PNG)-> S5 eSIM ---')
walkie_n = 0
if not DO_WALKIE:
print(' 按设置跳过这一组(ICON_WALKIE=1 可打开)')
elif not os.path.isdir(WALKIE_SRC):
print(' 没有 %s,跳过这一组' % WALKIE_SRC)
else:
rels = []
for root, _, files in os.walk(WALKIE_SRC):
for f in files:
if f.lower().endswith('.png'):
rels.append(os.path.relpath(os.path.join(root, f), WALKIE_SRC).replace('\\', '/'))
for rel in sorted(rels):
path = 'walkie_talkie/' + rel[:-4] + '.bin'
dev = 'app'
ti = find_inode(ROM_N[dev], path)
if ti is None:
skipped.append((path, '目标固件没有这个槽位')); continue
tgt_blob = ROM_N[dev].read(ti)
img = Image.open(os.path.join(WALKIE_SRC, rel))
new, how = ip.encode_into_slot(img, tgt_blob, ti.size)
if new is None:
skipped.append((path, how)); continue
if new == tgt_blob:
print(' %-52s 与现有一致,跳过' % path); continue
th = img_header(tgt_blob)
name = 'walk:' + path
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='walkie', path=path, note='对讲机素材', how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=path,
offset=ti.data_off, length=ti.size,
dim=('%dx%d' % (th['w'], th['h'])) if th else '?',
cf=ip.FMT.get(th['cf'], hex(th['cf'])) if th else '?',
stock_ink=None, source_from='用户 PNG assets/walkie_talkie/' + rel,
identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
walkie_n += 1
if walkie_n <= 6 or walkie_n % 40 == 0:
print(' %-52s %-18s %d B' % (path, how, ti.size))
print(' 对讲机素材写入 %d 条' % walkie_n)
# ------------------------------------------------- 控制中心顶部状态区(q63 -> S5 eSIM)
# q63 的顶栏素材在 control_center/bar/:wifi 4 档 / 蜂窝信号 5 档 / 3G·4G 文字 /
# 蓝牙连接·断开 / 飞行模式 / 定位。本机同名槽位在 control_center/icon/(48x48 I8)。
# 尺寸不同的(q63 那几张 36x36)按**实心范围**归一化(normalize(simg, ink_bbox(timg), ...))。
CC_TOP = [
('control_center/icon/network_4g.bin', 'control_center/bar/network_4g.bin', '4G 文字'),
('control_center/icon/network_3g.bin', 'control_center/bar/network_3g.bin', '3G 文字'),
('control_center/icon/signal_strength.bin', 'control_center/bar/signal_strength0.bin', '蜂窝信号 0'),
('control_center/icon/signal_strength1.bin', 'control_center/bar/signal_strength1.bin', '蜂窝信号 1'),
('control_center/icon/signal_strength2.bin', 'control_center/bar/signal_strength2.bin', '蜂窝信号 2'),
('control_center/icon/signal_strength3.bin', 'control_center/bar/signal_strength3.bin', '蜂窝信号 3'),
('control_center/icon/signal_strength4.bin', 'control_center/bar/signal_strength4.bin', '蜂窝信号 4'),
('control_center/icon/air_mode_conn.bin', 'control_center/bar/air.bin', '飞行模式'),
('control_center/icon/bt_connect.bin', 'control_center/bar/connect.bin', '蓝牙已连接手机'),
('control_center/icon/bt_connect_g.bin', 'control_center/bar/connect.bin', '蓝牙已连接手机(g)'),
('control_center/icon/bt_disconnect.bin', 'control_center/bar/disconnect.bin', '蓝牙未连接'),
('control_center/icon/location.bin', 'control_center/bar/local.bin', '定位'),
('control_center/icon/wifi_level_0.bin', 'control_center/bar/wifi_level_0.bin', 'WiFi 0'),
('control_center/icon/wifi_level_1.bin', 'control_center/bar/wifi_level_1.bin', 'WiFi 1'),
('control_center/icon/wifi_level_2.bin', 'control_center/bar/wifi_level_2.bin', 'WiFi 2'),
('control_center/icon/wifi_level_3.bin', 'control_center/bar/wifi_level_3.bin', 'WiFi 3'),
]
# 手机电量(本机 phone/icon/battery_level_*,64x22):q63 同名但 64x32
for _n in range(0, 11):
CC_TOP.append(('phone/icon/battery_level_%d.bin' % _n,
'phone/icon/battery_level_%d.bin' % _n, '手机电量 %d' % _n))
print('\n--- 控制中心顶部状态区(q63 control_center/bar -> S5 eSIM)---')
cc_top_n = 0
for tpath, spath, note in CC_TOP:
ti = find_inode(ROM_N['app'], tpath)
if ti is None:
ti = find_inode(ROM_N['health'], tpath)
dev = 'health'
else:
dev = 'app'
if ti is None:
skipped.append((tpath, '本机没有这个槽位')); continue
oi = find_inode(ROM_O[dev], spath)
if oi is None:
skipped.append((spath, 'q63 没有这个素材')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[dev].read(oi)
th = img_header(tgt_blob)
sh = img_header(src_blob)
timg = decode_lvgl_bin(tgt_blob)[1]
simg = decode_lvgl_bin(src_blob)[1]
if timg is None or simg is None:
skipped.append((tpath, '解码失败')); continue
out = normalize(simg, ink_bbox(timg), (th['w'], th['h']))
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
if len(new) != len(tgt_blob):
failed.append((tpath, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
if new == tgt_blob:
skipped.append((tpath, '两边已经一致')); continue
# 与 SYSICONS 组同名 -> 同一槽位只保留一条记录(这里覆盖那边的旧风格版本)
name = 'sys:' + tpath
rows[:] = [r for r in rows if r['name'] != name]
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=tpath, note=note, how='归一化(%s)' % note,
tag=dev, dev=dev, node='/dev/' + dev, image=tpath,
offset=ti.data_off, length=ti.size,
dim='%dx%d' % (th['w'], th['h']),
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
stock_ink=ink_bbox(timg), source_from='q63 ' + spath,
identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
cc_top_n += 1
print(' %-46s <- %-40s %s' % (tpath, spath, note))
print(' 顶栏状态区写入 %d 条' % cc_top_n)
# ------------------------------------------------- 米家 / 融合中心(控制中心里的米家设备图标)
# q63 的 fusion_center/** 与 mijia/** 里**同名路径**本机也有的就移植;本机还有一份
# fusion_center/gauss/**(同名的一并写)。画布尺寸两边常常不同(q63 116/64 vs 本机 96/128),
# 按**实心范围**对齐并转成本机槽位的格式。
PLATE_KEEP = ('headset',) # 只有这种「裸图案」槽位保留底版 + 图案归位
# 控制中心的米家/融合中心那组:**默认不打包**(用户 2026-10-02 决定不改)。ICON_MIJIA=1 可打开。
DO_MIJIA = os.environ.get('ICON_MIJIA') == '1'
# q63 的「柔光玻璃」底盘(干净的深色玻璃圆盘 + 发丝高光环)
_glass = None
_g = find_inode(ROM_O['app'], 'fusion_center/fg.bin')
if _g is not None:
_glass = decode_lvgl_bin(ROM_O['app'].read(_g))[1]
if _glass is not None:
_glass = _glass.convert('RGBA')
print('柔光玻璃盘: q63 fusion_center/fg.bin %dx%d' % _glass.size)
MIJIA_PREFIX = ('fusion_center/', 'mijia/')
def plate_glyph_bbox(img, tol=26, min_plate=0.30):
"""本机图标是「底版 + 图案」时,返回底版上图案的 bbox(底版色 = 出现最多的颜色)。"""
rgba = img.convert('RGBA')
w, h = rgba.size
px = rgba.load()
cnt = {}
for y in range(h):
for x in range(w):
p = px[x, y]
if p[3] > 16:
cnt[p] = cnt.get(p, 0) + 1
if not cnt:
return None
plate, n = max(cnt.items(), key=lambda kv: kv[1])
if n >= min_plate * w * h:
xs, ys = [], []
for y in range(h):
for x in range(w):
p = px[x, y]
if p[3] > 16 and (abs(p[0] - plate[0]) + abs(p[1] - plate[1]) + abs(p[2] - plate[2])) > tol:
xs.append(x); ys.append(y)
if xs:
return (min(xs), min(ys), max(xs), max(ys))
# 兜底:底版是渐变(没有主导纯色)时,用**亮度**找亮图案
lum = {}
mx = 0
for y in range(h):
for x in range(w):
p = px[x, y]
if p[3] > 16:
v = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]
lum[(x, y)] = v
mx = max(mx, v)
if not lum or mx <= 0:
return None
thr = 0.62 * mx
xs = [x for (x, y), v in lum.items() if v >= thr]
ys = [y for (x, y), v in lum.items() if v >= thr]
if not xs:
return None
bb = (min(xs), min(ys), max(xs), max(ys))
if (bb[2] - bb[0] + 1) * (bb[3] - bb[1] + 1) > 0.92 * w * h:
return None
return bb
def ink_ratio(img):
bb = ink_bbox(img)
if bb is None:
return 0.0
w, h = img.size
return max((bb[2] - bb[0] + 1) / w, (bb[3] - bb[1] + 1) / h)
def normalize_fit(src_img, target_bb, canvas):
"""等比缩放源实心区域到能放进 target_bb 的最大尺寸并居中(不拉伸)。"""
cw, ch = canvas
cv = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
if target_bb is None:
return cv
nb = ink_bbox(src_img)
if nb is None:
return cv
tw, th = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
s = min(tw / art.size[0], th / art.size[1])
nw, nh = max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))
if (nw, nh) != art.size:
up = s > 1.0
art = art.resize((nw, nh), Image.LANCZOS)
if up:
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
cv.paste(art, (target_bb[0] + (tw - nw) // 2, target_bb[1] + (th - nh) // 2))
return cv
def overlay_glyph_in_place(tgt_blob, th, src_img, target_bb):
"""在**索引层**把 q63 的裸图案画进本机 I8 图标:底版像素一个比特都不动。
底版是抖动/渐变,重新量化(用谁的调色板都一样)会出噪点或白环;只有直接改索引数组
底版才保持原样。图案像素按最近色映射到**本机自己的调色板**(本机图标里本来就有白色)。
"""
w, h, cf = th['w'], th['h'], th['cf']
if cf != ip.CF_I8 or (th['flags'] & 0x08):
return None
if len(tgt_blob) != 12 + 1024 + w * h:
return None
pal_b = tgt_blob[12:12 + 1024]
pal = [(pal_b[i * 4 + 2], pal_b[i * 4 + 1], pal_b[i * 4 + 0], pal_b[i * 4 + 3]) for i in range(256)]
idx = bytearray(tgt_blob[12 + 1024:12 + 1024 + w * h])
nb = ink_bbox(src_img)
if nb is None or target_bb is None:
return None
tw, thh = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
s = min(tw / art.size[0], thh / art.size[1])
art = art.resize((max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))), Image.LANCZOS)
ox = target_bb[0] + (tw - art.width) // 2
oy = target_bb[1] + (thh - art.height) // 2
cache = {}
for y in range(art.height):
for x in range(art.width):
r, g, b, a = art.getpixel((x, y))
if a < 24:
continue
key = (r, g, b, a)
ci = cache.get(key)
if ci is None:
best, bd = 0, 1 << 30
for k in range(256):
pr, pg, pb, pa = pal[k]
d = (pr - r) ** 2 + (pg - g) ** 2 + (pb - b) ** 2 + 2 * (pa - a) ** 2
if d < bd:
bd, best = d, k
cache[key] = ci = best
ix, iy = ox + x, oy + y
if 0 <= ix < w and 0 <= iy < h:
idx[iy * w + ix] = ci
return tgt_blob[:12 + 1024] + bytes(idx)
def plate_share(img, gbb):
"""底版像素占实心像素的比例 —— 确认这张图**真的有底版**(不是纯图案)。"""
if gbb is None:
return 0.0
a = img.convert('RGBA').getchannel('A')
w, h = a.size
px = a.load()
ink = tot = 0
for y in range(h):
for x in range(w):
if px[x, y] > 16:
tot += 1
if not (gbb[0] <= x <= gbb[2] and gbb[1] <= y <= gbb[3]):
ink += 1
return (ink / tot) if tot else 0.0
print('\n--- 米家 / 融合中心素材(q63 -> S5 eSIM)%s ---' % ('' if DO_MIJIA else ':已按设置跳过'))
mijia_n = 0
_mijia_srcs = []
if DO_MIJIA:
for _dev0 in ('app', 'health'):
for _p, _i in ROM_O[_dev0].entries():
if _i.type == 2 and _p.startswith(MIJIA_PREFIX) and not _p.endswith('launcher.bin'):
_mijia_srcs.append((_p, _dev0))
for _q, _srcdev in sorted(set(_mijia_srcs)):
cands = [_q]
if _q.startswith('fusion_center/icon/'):
cands.append(_q.replace('fusion_center/icon/', 'fusion_center/gauss/'))
for tpath in cands:
dev = 'app'
ti = find_inode(ROM_N[dev], tpath)
if ti is None:
ti = find_inode(ROM_N['health'], tpath)
dev = 'health'
if ti is None:
if tpath == _q:
skipped.append((tpath, '本机没有这个槽位'))
continue
oi = find_inode(ROM_O[_srcdev], _q)
if oi is None:
skipped.append((_q, 'q63 没有这个素材')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[_srcdev].read(oi)
th, sh = img_header(tgt_blob), img_header(src_blob)
if th is None or sh is None:
skipped.append((tpath, '头部异常')); continue
simg = decode_lvgl_bin(src_blob)[1]
if simg is None:
skipped.append((tpath, 'q63 素材解码失败(cf=0x%02x)' % sh['cf'])); continue
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
new, how = ip.encode_into_slot(simg, tgt_blob, ti.size)
else:
timg = decode_lvgl_bin(tgt_blob)[1]
if timg is None:
skipped.append((tpath, '本机图解码失败(cf=0x%02x)' % th['cf'])); continue
gbb = plate_glyph_bbox(timg)
pal = None
if (gbb is not None and ink_ratio(simg) < 0.90
and plate_share(timg, gbb) >= 0.45
and tpath.rsplit('/', 1)[-1][:-4] in PLATE_KEEP):
# 底版原样保留(索引层),只把 q63 的裸图案画进去
new = overlay_glyph_in_place(tgt_blob, th, simg, gbb)
if new is None:
skipped.append((tpath, '索引层合成不可用')); continue
out = None
how = '底版原样+图案入索引 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
else:
out = normalize_fit(simg, ink_bbox(timg), (th['w'], th['h']))
how = '归一化 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
if out is not None:
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
if new is None or len(new) != ti.size:
skipped.append((tpath, '编码失败/长度不符')); continue
if new == tgt_blob:
skipped.append((tpath, '两边已经一致')); continue
name = 'sys:' + tpath
rows[:] = [r for r in rows if r['name'] != name]
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=tpath, note='米家/融合中心', how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=tpath,
offset=ti.data_off, length=ti.size,
dim='%dx%d' % (th['w'], th['h']),
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
stock_ink=ink_bbox(timg) if 'timg' in dir() and timg else None,
source_from='q63 ' + _q, identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
mijia_n += 1
if mijia_n <= 8 or mijia_n % 20 == 0:
print(' %-52s <- %-34s %s' % (tpath, _q, how))
print(' 米家/融合中心写入 %d 条' % mijia_n)
# ------------------------------------------------- 焦点通知素材(q63 notifications/** -> 本机)
PORT_PREFIXES = ('notifications/', 'phone/', 'recorder/', 'timer/', 'todolist/')
# 用户指定:这两张保持原厂不换
NOTIF_SKIP = ('notifications/dotted_line.bin', 'notifications/track.bin',
# 用户指定:录音机这 3 张不换
'recorder/mark_opt.bin', 'recorder/menu.bin', 'recorder/confirm.bin',
# 用户指定:拨号列表这 3 张不换
'phone/icon/list_icon_keypad.bin', 'phone/icon/list_icon_call_log.bin',
'phone/icon/list_icon_contact.bin',
# 用户指定:组件预览底图这 2 张不换
'phone/widget/phone_widget.bin', 'phone/widget/pre_phone_widget.bin')
# 应用图标归 launcher 组管,这里跳过避免重复写同一槽位
PORT_SKIP_TAIL = ('launcher.bin', 'launcher')
print('\n--- 通知/电话/录音机素材(q63 同名移植 -> S5 eSIM)---')
notif_n = 0
for _q, _srcdev in sorted((p, 'app') for p, i in ROM_O['app'].entries()
if i.type == 2 and p.startswith(PORT_PREFIXES)):
if _q in NOTIF_SKIP:
skipped.append((_q, '按约定保持原厂')); continue
if _q.rsplit('/', 1)[-1].endswith('launcher.bin'):
continue # 应用图标归 launcher 组管
dev = 'app'
ti = find_inode(ROM_N[dev], _q)
if ti is None:
ti = find_inode(ROM_N['health'], _q)
dev = 'health'
if ti is None:
skipped.append((_q, '本机没有这个槽位')); continue
oi = find_inode(ROM_O[_srcdev], _q)
if oi is None:
skipped.append((_q, 'q63 没有这个素材')); continue
tgt_blob = ROM_N[dev].read(ti)
src_blob = ROM_O[_srcdev].read(oi)
th, sh = img_header(tgt_blob), img_header(src_blob)
if th is None or sh is None:
skipped.append((_q, '头部异常')); continue
simg = decode_lvgl_bin(src_blob)[1]
if simg is None:
skipped.append((_q, 'q63 素材解码失败(cf=0x%02x)' % sh['cf'])); continue
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
new, how = ip.encode_into_slot(simg, tgt_blob, ti.size)
if new is None:
skipped.append((_q, how)); continue
else:
timg = decode_lvgl_bin(tgt_blob)[1]
if timg is None:
skipped.append((_q, '本机图解码失败(cf=0x%02x)' % th['cf'])); continue
out = normalize_fit(simg, ink_bbox(timg), (th['w'], th['h']))
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
how = '归一化 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
if len(new) != ti.size:
skipped.append((_q, '编码 %d != 槽位 %d' % (len(new), ti.size))); continue
if new == tgt_blob:
skipped.append((_q, '两边已经一致')); continue
name = 'sys:' + _q
rows[:] = [r for r in rows if r['name'] != name]
newblobs[name] = new
stock_of[name] = tgt_blob
rows.append(dict(name=name, kind='sysicon', path=_q, note='焦点通知', how=how,
tag=dev, dev=dev, node='/dev/' + dev, image=_q,
offset=ti.data_off, length=ti.size,
dim='%dx%d' % (th['w'], th['h']),
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
stock_ink=ink_bbox(timg) if 'timg' in dir() and timg else None,
source_from='q63 ' + _q, identical_to_stock=False,
sha256=hashlib.sha256(new).hexdigest(),
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
notif_n += 1
print(' %-46s %s' % (_q, how))
print(' 通知/电话/录音机写入 %d 条' % notif_n)
# ------------------------------------------------------------------ payload files
pay = MAGIC_PAY + b''.join(newblobs[r['name']] for r in rows)
rb = MAGIC_RB + b''.join(stock_of[r['name']] for r in rows)
open(os.path.join(WF, 'payload', 'payload.bin'), 'wb').write(pay)
open(os.path.join(WF, 'payload_rollback', 'rollback.bin'), 'wb').write(rb)
def lua_table(recs, var):
lines = ['-- %s: {设备, 分区内偏移, 长度, payload 文件内相对偏移} —— 由 _tools/s5e_payload.py 生成' % var,
'local %s = {' % var]
rel = 0
for r in recs:
lines.append(' { "%s", 0x%08x, %d, %d },' % (r['dev'], r['offset'], r['length'], rel))
rel += len(stock_of[r['name']])
lines.append('}')
return '\n'.join(lines) + '\n'
open(os.path.join(WF, 'payload', 'payload.lua'), 'w', encoding='utf-8', newline='\n').write(
lua_table(rows, 'PAY'))
open(os.path.join(WF, 'payload_rollback', 'rollback.lua'), 'w', encoding='utf-8', newline='\n').write(
lua_table(rows, 'RB'))
json.dump(dict(target='Xiaomi Watch S5 eSIM 46mm (p62lte) v3.112.035',
source='Xiaomi Watch S5 41mm (q63) v4.101.020 launcher icons',
device_nodes={'app': '/dev/app', 'health': '/dev/health'},
payload_file='payload.bin', payload_magic=MAGIC_PAY.hex(),
rollback_file='rollback.bin', rollback_magic=MAGIC_RB.hex(),
count=len(rows), records=rows,
skipped=[dict(name=n, reason=w) for n, w in skipped]),
open(os.path.join(OUT, 'manifest.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
# ------------------------------------------------------------------ patched whole images
for tag, fn, dev in PART:
srcp = os.path.join(NEW, fn)
dstp = os.path.join(OUT, fn.replace('.bin', '_os4.bin'))
shutil.copyfile(srcp, dstp)
with open(dstp, 'r+b') as f:
for r in rows:
if r['tag'] == tag:
f.seek(r['offset'])
f.write(newblobs[r['name']])
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(srcp).entries()]
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(dstp).entries()]
r1, r2 = Romfs(srcp), Romfs(dstp)
i1 = {p: i for p, i in r1.entries()}
i2 = {p: i for p, i in r2.entries()}
changed = sorted(p for p in i1 if r1.read(i1[p]) != r2.read(i2[p]))
# 期望变化的文件 = 所有 new != stock 的记录(launcher 图标 + 系统 UI 素材)
mine = [r for r in rows if r['tag'] == tag and newblobs[r['name']] != stock_of[r['name']]]
sys_paths = {r['path'] for r in mine if r.get('kind') in ('sysicon', 'walkie')}
unexpected = [p for p in changed
if not (p.rsplit('/', 1)[-1].endswith('launcher.bin') or p in sys_paths)]
print('\n%s -> %s' % (fn, os.path.basename(dstp)))
print(' entries %d, inode table identical: %s, superblock identical: %s' % (
len(a), a == b, (r1.sb_size, r1.sb_cksum) == (r2.sb_size, r2.sb_cksum)))
print(' changed files: %d (expected %d) sha256 %s' % (
len(changed), len(mine), hashlib.sha256(open(dstp, 'rb').read()).hexdigest()[:16]))
assert not unexpected, 'unexpected changed file! %s' % unexpected[:3]
assert len(changed) == len(mine), 'changed %d != expected %d' % (len(changed), len(mine))
# 端到端:把负载按 (偏移,长度) 放回去,必须与改后镜像逐字节相同
d = bytearray(open(srcp, 'rb').read())
for r in rows:
if r['tag'] == tag:
d[r['offset']:r['offset'] + r['length']] = newblobs[r['name']]
assert bytes(d) == open(dstp, 'rb').read(), 'payload replay != patched image'
print(' payload replay == patched image: True')
# 回滚也必须能逐字节还原原厂镜像
e = bytearray(open(dstp, 'rb').read())
for r in rows:
if r['tag'] == tag:
e[r['offset']:r['offset'] + r['length']] = stock_of[r['name']]
orig = open(srcp, 'rb').read()
assert bytes(e) == orig, 'rollback replay != stock image'
print(' rollback replay == stock image: True (sha256 %s)'
% hashlib.sha256(orig).hexdigest()[:16])
print('\n-> %s' % OUT)