Initial Commit
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""overlay + 官方模板 -> Compiler.exe -> .face
|
||||
|
||||
要点(都是踩过的坑):
|
||||
* preview.png 必须是 326x326,Compiler.exe 会直接拒绝别的尺寸
|
||||
* watchfaceId 要带机型家族前缀:S4 家族 = 362150xxx(固件自带表盘是 362150004..7)
|
||||
* 用 Python 的 copytree 拷目录,别用 PowerShell 的 Copy-Item -Recurse(会拍平)
|
||||
"""
|
||||
import os, json, shutil, subprocess, sys
|
||||
from paths import WORK, TEMPLATE
|
||||
|
||||
TPL = TEMPLATE
|
||||
OVER = os.path.join(WORK, 'overlay')
|
||||
BUILD = os.path.join(WORK, 'build')
|
||||
|
||||
# name -> (watchfaceId, description)
|
||||
TARGETS = {'S5Icons': ('362150101', 'S5 41mm OS4 icons -> S4 eSIM')}
|
||||
|
||||
|
||||
def prepare(name):
|
||||
oid, _ = TARGETS[name]
|
||||
ov = os.path.join(OVER, name)
|
||||
dst = os.path.join(BUILD, name)
|
||||
if not os.path.exists(TPL):
|
||||
raise SystemExit('找不到官方模板:%s\n请把 FangAiden/LuaDevTemplate 放到 vendor/ 下(见 README)' % TPL)
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(TPL, dst)
|
||||
|
||||
old = os.path.join(dst, 'watchface', 'fprj', 'LuaDevTemplate.fprj')
|
||||
if os.path.exists(old):
|
||||
os.remove(old)
|
||||
|
||||
cfg = json.load(open(os.path.join(ov, 'watchface.config.json'), encoding='utf-8'))
|
||||
cfg['watchfaceId'] = oid
|
||||
json.dump(cfg, open(os.path.join(dst, 'watchface.config.json'), 'w', encoding='utf-8'),
|
||||
indent=4, ensure_ascii=False)
|
||||
|
||||
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', f'{name}.fprj'),
|
||||
os.path.join(dst, 'watchface', 'fprj', f'{name}.fprj'))
|
||||
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'),
|
||||
os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png'))
|
||||
shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'),
|
||||
os.path.join(dst, 'watchface', 'fprj', 'app', 'lua', 'main.lua'))
|
||||
|
||||
img = os.path.join(dst, 'watchface', 'fprj', 'app', 'images')
|
||||
os.makedirs(img, exist_ok=True)
|
||||
ovimg = os.path.join(ov, 'watchface', 'fprj', 'app', 'images')
|
||||
for d in sorted(x for x in os.listdir(ovimg) if os.path.isdir(os.path.join(ovimg, x))):
|
||||
tgt = os.path.join(img, d)
|
||||
if os.path.exists(tgt):
|
||||
shutil.rmtree(tgt)
|
||||
shutil.copytree(os.path.join(ovimg, d), tgt)
|
||||
print(f' {name}: app/images/{d}/ -> {len(os.listdir(tgt))} files')
|
||||
return dst
|
||||
|
||||
|
||||
def build(dst):
|
||||
ps = os.path.join(dst, 'scripts', 'build_face.ps1')
|
||||
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps],
|
||||
capture_output=True, text=True, errors='replace')
|
||||
for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]:
|
||||
print(' |', l)
|
||||
if r.returncode != 0:
|
||||
print(' !! build failed, rc=', r.returncode)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
only = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
for name in TARGETS:
|
||||
if only and name != only:
|
||||
continue
|
||||
print(f'== {name} (id={TARGETS[name][0]}) ==')
|
||||
dst = prepare(name)
|
||||
ok = build(dst)
|
||||
face = os.path.join(dst, 'bin', f'{name}.face')
|
||||
if os.path.exists(face):
|
||||
print(f' OK {face} {os.path.getsize(face)} bytes')
|
||||
else:
|
||||
print(' missing output')
|
||||
@@ -0,0 +1,58 @@
|
||||
"""用真实渲染结果判"会不会被圆屏切掉"。
|
||||
|
||||
做法:渲染一张**不套圆形遮罩**的 RGBA,然后量所有非透明像素到圆心的距离。
|
||||
这样自动考虑字体实际字宽、折行、圆角、图片缩放 —— 比按元素外框估算准得多。
|
||||
"""
|
||||
import os, sys, math
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from PIL import Image, ImageDraw
|
||||
from render_ui import draw_label, W, H
|
||||
from pages import PAGES
|
||||
|
||||
CX = CY = 233.0
|
||||
R = 233.0
|
||||
|
||||
|
||||
def render_raw(items):
|
||||
img = Image.new('RGBA', (W, None) if False else (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
from render_ui import _rgb, font
|
||||
for it in items:
|
||||
k = it.get('kind')
|
||||
if k == 'rect':
|
||||
box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1]
|
||||
r = it.get('radius', 0)
|
||||
if r:
|
||||
d.rounded_rectangle(box, radius=r, fill=_rgb(it['bg']) + (255,))
|
||||
else:
|
||||
d.rectangle(box, fill=_rgb(it['bg']) + (255,))
|
||||
elif k == 'circle':
|
||||
d.ellipse([it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1],
|
||||
fill=_rgb(it['bg']) + (255,))
|
||||
elif k == 'image':
|
||||
if os.path.exists(it['src']):
|
||||
im = Image.open(it['src']).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS)
|
||||
img.alpha_composite(im, (it['x'], it['y']))
|
||||
elif k == 'label':
|
||||
draw_label(img, d, it)
|
||||
return img
|
||||
|
||||
|
||||
print(f'{"page":<10} {"max r":>7} {"outside px":>11} worst point / note')
|
||||
print('-' * 78)
|
||||
for name, items in PAGES.items():
|
||||
im = render_raw(items)
|
||||
px = im.load()
|
||||
worst, wpt, outside = 0.0, None, 0
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
if px[x, y][3] > 8:
|
||||
d = math.hypot(x - CX, y - CY)
|
||||
if d > worst:
|
||||
worst, wpt = d, (x, y)
|
||||
if d > R:
|
||||
outside += 1
|
||||
note = '全部在圆内' if worst <= R else f'会被切 {outside} px'
|
||||
print(f'{name:<10} {worst:>7.1f} {outside:>11} {wpt} {note}')
|
||||
print('-' * 78)
|
||||
print(f'圆心 ({CX:.0f},{CY:.0f}) 半径 {R:.0f} 画布 {W}x{H}')
|
||||
@@ -0,0 +1,68 @@
|
||||
"""产出最终 .face。
|
||||
|
||||
* Compiler.exe 写 byte5 = 0x00 + 自己的随机 id 到 40..49
|
||||
* 官方 set_face_id.ps1 会把 byte5 改成 10 并写 id
|
||||
* 固件里 25 个真表盘 byte5 ∈ {0,1},从不为 10
|
||||
=> 忠实产物 = Compiler.exe 原始输出,只替换 40..49(我们称 b5=0 那份)
|
||||
同时保留官方工具链那份(b5=10),装不上时可以换。
|
||||
"""
|
||||
import os, shutil, subprocess, hashlib
|
||||
from paths import WORK, DIST
|
||||
|
||||
BUILD = os.path.join(WORK, 'build')
|
||||
TARGETS = {'S5Icons': '362150101'}
|
||||
|
||||
|
||||
def compile_raw(name):
|
||||
w = os.path.join(BUILD, name)
|
||||
exe = os.path.join(w, 'watchface', 'tools', 'Compiler.exe')
|
||||
if not os.path.exists(exe):
|
||||
print(f' {name}: 跳过(没有构建树,先跑 build_faces.py {name})')
|
||||
return None
|
||||
fprj = os.path.join(w, 'watchface', 'fprj', f'{name}.fprj')
|
||||
out = os.path.join(WORK, 'raw_' + name)
|
||||
if os.path.exists(out):
|
||||
shutil.rmtree(out)
|
||||
os.makedirs(out)
|
||||
subprocess.run([exe, '-b', fprj, out, f'{name}.face', TARGETS[name]],
|
||||
capture_output=True, text=True, errors='replace')
|
||||
p = os.path.join(out, f'{name}.face')
|
||||
return p if os.path.exists(p) else None
|
||||
|
||||
|
||||
def patch_id(src, dst, wid):
|
||||
d = bytearray(open(src, 'rb').read())
|
||||
if d[0:4] != b'\x5a\xa5\x34\x12':
|
||||
raise SystemExit('bad magic')
|
||||
if len(wid) > 10:
|
||||
raise SystemExit('id too long')
|
||||
d[40:50] = wid.encode() + b'\x00' * (10 - len(wid))
|
||||
open(dst, 'wb').write(bytes(d))
|
||||
return dst
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(DIST, exist_ok=True)
|
||||
report = []
|
||||
for name, wid in TARGETS.items():
|
||||
raw = compile_raw(name)
|
||||
if not raw:
|
||||
continue
|
||||
faithful = patch_id(raw, os.path.join(DIST, f'{name}.face'), wid)
|
||||
tc_src = os.path.join(BUILD, name, 'bin', f'{name}.face')
|
||||
tc = os.path.join(DIST, f'{name}_toolchain.face')
|
||||
if os.path.exists(tc_src):
|
||||
shutil.copyfile(tc_src, tc)
|
||||
for p in (faithful, tc):
|
||||
if os.path.exists(p):
|
||||
d = open(p, 'rb').read()
|
||||
report.append((os.path.basename(p), len(d), d[4], d[5], d[40:50],
|
||||
hashlib.sha256(d).hexdigest()[:16]))
|
||||
print(f'{"file":<32}{"bytes":>10} b4 b5 id sha256[:16]')
|
||||
for f, n, b4, b5, sid, h in report:
|
||||
print(f'{f:<32}{n:>10} {b4:<3} {b5:<3} {sid!r:<20} {h}')
|
||||
print('->', DIST)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""规范化构建:表盘源码 + 资源 -> .face(两遍,自动回填 resource.bin 内的偏移)
|
||||
|
||||
main.lua <- **源码**,含 __UIIMG__ / __PAY__ / __RB__ 占位符
|
||||
.work/overlay/S5Icons <- 中间产物(回填后的 Lua 落在这里)
|
||||
.work/build/S5Icons <- 编译工作区(Compiler.exe 的实际输入)
|
||||
dist/S5Icons.face <- 交付物
|
||||
|
||||
为什么要两遍:.face 里各资源的偏移取决于打包顺序,我们靠在 .face 里搜文件字节来定位。
|
||||
Lua 源码排在所有资源**之后**,所以回填(让 Lua 变长)不会移动前面资源的位置 —— 第一遍量出来的
|
||||
偏移在第二遍依然成立,脚本最后会逐条校验。
|
||||
"""
|
||||
import os, re, sys, json, subprocess
|
||||
from paths import PROJ, WORK
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(PROJ, 'main.lua')
|
||||
OVERLAY_APP = os.path.join(WORK, 'overlay', 'S5Icons', 'watchface', 'fprj', 'app')
|
||||
RESOLVED = os.path.join(OVERLAY_APP, 'lua', 'main.lua')
|
||||
FACE = os.path.join(WORK, 'build', 'S5Icons', 'bin', 'S5Icons.face')
|
||||
OFFS = os.path.join(WORK, 'face_payload_offsets.json')
|
||||
PY = sys.executable
|
||||
|
||||
UINAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback',
|
||||
'ic_qq', 'logo']
|
||||
|
||||
|
||||
def run(script, *args):
|
||||
r = subprocess.run([PY, os.path.join(TOOLS, script)] + list(args),
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace')
|
||||
if r.returncode != 0:
|
||||
print(r.stdout[-2000:]); print(r.stderr[-2000:])
|
||||
raise SystemExit(f'{script} 执行失败')
|
||||
return r.stdout
|
||||
|
||||
|
||||
def find_blob(face, blob):
|
||||
hits, start = [], 0
|
||||
while True:
|
||||
i = face.find(blob, start)
|
||||
if i < 0:
|
||||
break
|
||||
hits.append(i)
|
||||
start = i + 1
|
||||
if len(hits) > 4:
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def payload_rows(tag, lua_payload, offs):
|
||||
src = open(lua_payload, encoding='utf-8').read()
|
||||
tgt = {}
|
||||
for m in re.finditer(r'file = "([^"]+)", offset = (\d+), length = (\d+)', src):
|
||||
tgt[m.group(1)] = (int(m.group(2)), int(m.group(3)))
|
||||
rows = []
|
||||
for name, ln, hits, sig in offs[tag]:
|
||||
if name not in tgt:
|
||||
continue
|
||||
blob = min(hits) if tag == 'payload' else max(hits)
|
||||
to, tl = tgt[name]
|
||||
rows.append((blob, to, tl))
|
||||
rows.sort(key=lambda r: r[1])
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
tpl = open(SRC, encoding='utf-8').read()
|
||||
if '__UIIMG__' not in tpl:
|
||||
raise SystemExit('main.lua 里没有占位符 —— 它应该是源码模板,不要把回填后的版本存回去')
|
||||
print('第 1 遍:用占位符编一次,量出资源在 resource.bin 里的位置')
|
||||
run('make_overlays.py')
|
||||
run('build_faces.py', 'S5Icons')
|
||||
face = open(FACE, 'rb').read()
|
||||
print(f' face {len(face)} bytes')
|
||||
|
||||
ui_rows, bad = [], 0
|
||||
for name in UINAMES:
|
||||
blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read()
|
||||
hits = find_blob(face, blob)
|
||||
if len(hits) != 1:
|
||||
print(f' !! {name} 命中 {len(hits)} 次(应为 1)')
|
||||
bad += 1
|
||||
if hits:
|
||||
ui_rows.append((hits[0], len(blob), name))
|
||||
print(f' {name:<12} off=0x{hits[0]:x} len={len(blob)}')
|
||||
if bad:
|
||||
raise SystemExit('有资源无法唯一定位,检查是否有重复内容')
|
||||
|
||||
# 负载是「魔数 + 顺序拼接」的**单个条目**(blob/),偏移 = 魔数位置 + 魔数长度 + 相对偏移
|
||||
blobs = json.load(open(os.path.join(PROJ, 'blob', 'offsets.json'), encoding='utf-8'))
|
||||
pay_blob = open(os.path.join(PROJ, 'blob', 'payload.bin'), 'rb').read()
|
||||
rb_blob = open(os.path.join(PROJ, 'blob', 'rollback.bin'), 'rb').read()
|
||||
ml = blobs['magic_len']
|
||||
pay_base = face.find(pay_blob[:15])
|
||||
rb_base = face.find(rb_blob[:15])
|
||||
if face.count(pay_blob[:15]) != 1 or face.count(rb_blob[:15]) != 1:
|
||||
raise SystemExit('魔数命中次数 != 1(payload %d / rollback %d)'
|
||||
% (face.count(pay_blob[:15]), face.count(rb_blob[:15])))
|
||||
# 单条目必须**原样连续**存放,否则「base + 魔数长度 + 相对偏移」不成立
|
||||
if face.find(pay_blob) != pay_base or face.find(rb_blob) != rb_base:
|
||||
raise SystemExit('负载不是原样连续存放')
|
||||
pay = [(pay_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']]
|
||||
rb = [(rb_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']]
|
||||
print(f' 单条目:payload @0x{pay_base:x}({len(pay_blob)} B)/ rollback @0x{rb_base:x}'
|
||||
f'({len(rb_blob)} B),共 {len(pay)} 条记录')
|
||||
|
||||
ui_txt = '\n'.join(f' {{{o},{l},"{n}"}},' for o, l, n in ui_rows)
|
||||
pay_txt = ' pay = {\n' + '\n'.join(f' {{{o},{t},{l}}},' for o, t, l in pay) + '\n },'
|
||||
rb_txt = ' rb = {\n' + '\n'.join(f' {{{o},{t},{l}}},' for o, t, l in rb) + '\n },'
|
||||
resolved = (tpl.replace('__UIIMG__', ui_txt)
|
||||
.replace('__PAY__', pay_txt)
|
||||
.replace('__RB__', rb_txt))
|
||||
assert '__UIIMG__' not in resolved and 'pay = {}' not in resolved
|
||||
os.makedirs(os.path.dirname(RESOLVED), exist_ok=True)
|
||||
open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved)
|
||||
print(f'第 2 遍:回填后的 Lua 写入 overlay({len(resolved)} bytes),重新编译')
|
||||
|
||||
run('build_faces.py', 'S5Icons')
|
||||
print(run('finalize_faces.py').strip())
|
||||
|
||||
face2 = open(FACE, 'rb').read()
|
||||
drift = 0
|
||||
for name in UINAMES:
|
||||
blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read()
|
||||
hits = find_blob(face2, blob)
|
||||
row = [r for r in ui_rows if r[2] == name]
|
||||
if not row or not hits or hits[0] != row[0][0]:
|
||||
print(f' !! 偏移漂移 {name}')
|
||||
drift += 1
|
||||
for tag, rows, d in (('pay', pay, os.path.join(PROJ, 'payload')),
|
||||
('rb', rb, os.path.join(PROJ, 'payload_rollback'))):
|
||||
files = [(f, open(os.path.join(d, f), 'rb').read())
|
||||
for f in sorted(os.listdir(d)) if f.endswith('.bin')]
|
||||
ok = sum(1 for off, tgt, ln in rows
|
||||
if any(len(x) == ln and face2[off:off + ln] == x for _, x in files))
|
||||
if ok != len(rows):
|
||||
print(f' !! {tag} 只有 {ok}/{len(rows)} 条吻合'); drift += 1
|
||||
print(f'偏移校验:{"全部吻合,0 漂移" if drift == 0 else str(drift) + " 处异常"}')
|
||||
print('交付物:', os.path.join(PROJ, 'dist', 'S5Icons.face'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Build / apply a system-app launcher-icon replacement for a Vela `vela_app.bin`.
|
||||
|
||||
Why "same length" is the whole ball game
|
||||
----------------------------------------
|
||||
ROMFS packs superblock -> inode -> name -> file data strictly back to back, and
|
||||
every `next` / `spec` pointer is an ABSOLUTE offset. If a replacement file
|
||||
changes length by even one byte, every later offset shifts and the image is
|
||||
corrupt. So a replacement must encode to EXACTLY the original byte length:
|
||||
|
||||
* source PNG == original dimensions -> encode, lengths match, done.
|
||||
* source PNG != original dimensions -> two options:
|
||||
(default) resize to the original dimensions (icon renders identically)
|
||||
--pad keep the source dimensions, zero-pad the container to the
|
||||
original length (renders smaller unless the launcher scales)
|
||||
|
||||
Usage:
|
||||
iconpack.py list <vela_app.bin>
|
||||
iconpack.py make <vela_app.bin> <pngdir> <outdir> [--pad] [--patched]
|
||||
iconpack.py apply <vela_app.bin> <patch.bin> <out.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 rle_compress, pad_rle
|
||||
from PIL import Image
|
||||
|
||||
CF_ARGB8888, CF_I8, MAGIC = 0x10, 0x0a, 0x19
|
||||
FMT = {CF_ARGB8888: 'ARGB8888', CF_I8: 'I8'}
|
||||
RESAMPLE = Image.LANCZOS
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- encoders
|
||||
def encode_argb8888(img):
|
||||
rgba = img.convert('RGBA')
|
||||
w, h = rgba.size
|
||||
px = rgba.load()
|
||||
out = bytearray()
|
||||
for y in range(h):
|
||||
row = bytearray()
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
row += bytes((b, g, r, a))
|
||||
out += row
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_i8(img, alpha_levels=16, sharpen_up=0.0):
|
||||
"""I8 encoder with an ALPHA-AWARE palette.
|
||||
|
||||
The naive version quantised RGB only and then stored one *averaged* alpha
|
||||
per palette entry. Any colour shared by an opaque body pixel and a
|
||||
semi-transparent edge pixel collapsed to a mid alpha, and fully transparent
|
||||
pixels inherited that same entry -- so the transparent margin became faintly
|
||||
visible (measured: perpetual_calendar haze 0.000 -> 1.000, i.e. a grey veil
|
||||
around the whole icon).
|
||||
|
||||
Here the palette is built over (quantised RGB, quantised alpha) PAIRS, so a
|
||||
colour can own several entries at different alphas and alpha 0 stays alpha 0.
|
||||
"""
|
||||
rgba = img.convert('RGBA')
|
||||
w, h = rgba.size
|
||||
src = rgba.load()
|
||||
|
||||
# average colour of the visible pixels: transparent pixels must not invent
|
||||
# their own colour clusters
|
||||
rs = gs = bs = n = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = src[x, y]
|
||||
if a > 0:
|
||||
rs += r; gs += g; bs += b; n += 1
|
||||
avg = (rs // n, gs // n, bs // n) if n else (0, 0, 0)
|
||||
|
||||
step = max(1, 256 // max(1, alpha_levels))
|
||||
flat = Image.new('RGB', (w, h))
|
||||
fp = flat.load()
|
||||
qa = [[0] * w for _ in range(h)]
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = src[x, y]
|
||||
fp[x, y] = (r, g, b) if a > 0 else avg
|
||||
q = int(round(a / float(step))) * step
|
||||
qa[y][x] = 255 if q > 255 else q
|
||||
|
||||
p = flat.quantize(colors=256, method=Image.MEDIANCUT)
|
||||
pal_rgb = p.getpalette()[:768]
|
||||
idx = p.tobytes()
|
||||
|
||||
cnt = {}
|
||||
for y in range(h):
|
||||
row = y * w
|
||||
for x in range(w):
|
||||
k = (idx[row + x], qa[y][x])
|
||||
cnt[k] = cnt.get(k, 0) + 1
|
||||
keep = sorted(cnt.items(), key=lambda kv: -kv[1])[:256]
|
||||
|
||||
pal = bytearray()
|
||||
remap = {}
|
||||
by_rgb = {}
|
||||
for slot, ((pi, al), _c) in enumerate(keep):
|
||||
r = pal_rgb[pi * 3] if pi * 3 + 2 < len(pal_rgb) else 0
|
||||
g = pal_rgb[pi * 3 + 1] if pi * 3 + 2 < len(pal_rgb) else 0
|
||||
b = pal_rgb[pi * 3 + 2] if pi * 3 + 2 < len(pal_rgb) else 0
|
||||
pal += bytes((b, g, r, al))
|
||||
remap[(pi, al)] = slot
|
||||
by_rgb.setdefault(pi, []).append((al, slot))
|
||||
removed = 256 - len(keep)
|
||||
if removed > 0:
|
||||
pal += b'\x00\x00\x00\x00' * removed
|
||||
for pi in by_rgb:
|
||||
by_rgb[pi].sort()
|
||||
|
||||
out = bytearray(w * h)
|
||||
for y in range(h):
|
||||
row = y * w
|
||||
for x in range(w):
|
||||
pi = idx[row + x]
|
||||
al = qa[y][x]
|
||||
s = remap.get((pi, al))
|
||||
if s is None:
|
||||
cand = by_rgb.get(pi)
|
||||
if cand:
|
||||
s = min(cand, key=lambda t: abs(t[0] - al))[1]
|
||||
else:
|
||||
s = 0
|
||||
out[row + x] = s
|
||||
return bytes(pal) + bytes(out)
|
||||
|
||||
|
||||
def encode_i8_palette(img, pal_bgra, alpha_tol=12):
|
||||
"""Quantise to an EXISTING 256-entry BGRA palette (as stored in an I8 blob).
|
||||
|
||||
Reusing the source icon's own palette preserves the artwork's gradient shades
|
||||
and its anti-aliased edge levels. A freshly clustered palette cannot: the
|
||||
plate gradient eats the 256 entries, the edge collapses to a few alpha steps
|
||||
and dropped pixels land on wrong entries -- measured on remote_camera as a
|
||||
jagged plate rim plus stray coloured speckles.
|
||||
|
||||
The search is constrained by alpha so that
|
||||
* fully transparent source pixels can only pick transparent entries
|
||||
(no veil, no speckles), and
|
||||
* fully opaque source pixels can only pick opaque entries
|
||||
(the body never goes translucent).
|
||||
"""
|
||||
rgba = img.convert('RGBA')
|
||||
w, h = rgba.size
|
||||
px = rgba.load()
|
||||
|
||||
ent = [(pal_bgra[i * 4 + 2], pal_bgra[i * 4 + 1],
|
||||
pal_bgra[i * 4], pal_bgra[i * 4 + 3]) for i in range(256)]
|
||||
clear = [i for i, e in enumerate(ent) if e[3] == 0]
|
||||
if not clear: # guarantee a transparent entry exists
|
||||
worst = min(range(256), key=lambda i: ent[i][3])
|
||||
ent[worst] = (0, 0, 0, 0)
|
||||
clear = [worst]
|
||||
solid = [i for i, e in enumerate(ent) if e[3] >= 248]
|
||||
by_alpha = {}
|
||||
for i, e in enumerate(ent):
|
||||
by_alpha.setdefault(e[3], []).append(i)
|
||||
|
||||
def premul(e):
|
||||
r, g, b, a = e
|
||||
return (r * a // 255, g * a // 255, b * a // 255, a)
|
||||
|
||||
P = [premul(e) for e in ent]
|
||||
cache = {}
|
||||
out = bytearray(w * h)
|
||||
for y in range(h):
|
||||
row = y * w
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
key = (r >> 2, g >> 2, b >> 2, a >> 3)
|
||||
s = cache.get(key)
|
||||
if s is None:
|
||||
if a == 0:
|
||||
cand = clear
|
||||
elif a >= 248 and solid:
|
||||
cand = solid
|
||||
else:
|
||||
cand = []
|
||||
for da in range(0, alpha_tol + 1):
|
||||
cand += by_alpha.get(a + da, ()) if a + da <= 255 else ()
|
||||
if da:
|
||||
cand += by_alpha.get(a - da, ()) if a - da >= 0 else ()
|
||||
if not cand:
|
||||
cand = range(256)
|
||||
pr, pg, pb, pa = premul((r, g, b, a))
|
||||
best, bd = cand[0], None
|
||||
for i in cand:
|
||||
q = P[i]
|
||||
d = ((pr - q[0]) ** 2 + (pg - q[1]) ** 2 + (pb - q[2]) ** 2
|
||||
+ 2 * (pa - q[3]) ** 2)
|
||||
if bd is None or d < bd:
|
||||
bd, best = d, i
|
||||
s = best
|
||||
cache[key] = s
|
||||
out[row + x] = s
|
||||
|
||||
pal = bytearray()
|
||||
for (r, g, b, a) in ent:
|
||||
pal += bytes((b, g, r, a))
|
||||
return bytes(pal) + bytes(out)
|
||||
|
||||
|
||||
def encode(img, cf, w, h, flags=0, pad_to=None, palette=None):
|
||||
if cf == CF_ARGB8888:
|
||||
body = encode_argb8888(img)
|
||||
elif palette is not None:
|
||||
body = encode_i8_palette(img, palette)
|
||||
else:
|
||||
body = encode_i8(img)
|
||||
stride = w * (4 if cf == CF_ARGB8888 else 1)
|
||||
blob = struct.pack('<BBHHHHH', MAGIC, cf, flags, w, h, stride, 0) + body
|
||||
if pad_to is not None:
|
||||
if len(blob) > pad_to:
|
||||
raise ValueError(f'encoded {len(blob)} > slot {pad_to}')
|
||||
blob += b'\x00' * (pad_to - len(blob))
|
||||
return blob
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- helpers
|
||||
def raw_i8_strided(img, stride, colors=256):
|
||||
"""I8 像素流:1024 B 调色板(BGRA)+ stride*h 个索引(行尾按 header 的 stride 补 0)。
|
||||
|
||||
RLE 槽位专用:用 FASTOCTREE 自适应减色 + 关闭抖动 —— 颜色越少、相邻像素越容易出现
|
||||
长游程,RLE 流就越短(这是能不能塞进原 csize 的关键)。
|
||||
"""
|
||||
q = img.convert('RGBA').quantize(colors=max(2, min(256, colors)),
|
||||
method=Image.FASTOCTREE, dither=Image.NONE)
|
||||
if q.palette.mode == 'RGBA':
|
||||
pal = list(q.getpalette('RGBA'))
|
||||
else:
|
||||
rgb = q.getpalette('RGB')
|
||||
pal = [v for i in range(256) for v in (rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2], 255)]
|
||||
pal = (pal + [0] * 1024)[:1024]
|
||||
pal_b = bytearray()
|
||||
for i in range(256): # 存储序:B, G, R, A
|
||||
r, g, b, a = pal[i * 4:i * 4 + 4]
|
||||
pal_b += bytes((b, g, r, a))
|
||||
idx = q.tobytes()
|
||||
w, h = img.size
|
||||
rows = bytearray()
|
||||
for y in range(h):
|
||||
rows += idx[y * w:(y + 1) * w]
|
||||
if stride > w:
|
||||
rows += b'\x00' * (stride - w)
|
||||
return bytes(pal_b) + bytes(rows)
|
||||
|
||||
|
||||
# 从大到小试:优先保真,装不下再减色
|
||||
RLE_COLOR_STEPS = (256, 192, 160, 128, 96, 80, 64, 48, 40, 32, 24, 16, 12, 8, 6, 4)
|
||||
|
||||
|
||||
def encode_into_slot(img, stock_blob, slot_len=None, colors=None):
|
||||
"""把 img 编成与 stock_blob **逐字节等长** 的 LVGL v9 blob(沿用原 header)。
|
||||
|
||||
真实固件里这些 UI 素材有两种槽位形态,都必须支持,否则 ROMFS 后面所有偏移全平移:
|
||||
* ARGB8888 未压缩(144x144 图标那种):像素流 + 零填充到原长度
|
||||
* I8 + RLE 压缩(walkie_talkie 里几乎全是):量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize
|
||||
(颜色数从 256 逐级往下试,取第一个能塞进原 csize 的)
|
||||
返回 (blob, how);不支持 / 压缩后仍超长时返回 (None, 原因)。
|
||||
"""
|
||||
h = img_header(stock_blob)
|
||||
if h is None:
|
||||
return None, '不是 LVGL bin'
|
||||
w, ht, cf, flags = h['w'], h['h'], h['cf'], h['flags']
|
||||
if slot_len is None:
|
||||
slot_len = len(stock_blob)
|
||||
out_img = img.convert('RGBA')
|
||||
if out_img.size != (w, ht):
|
||||
# 约定:**长宽比不一致的素材直接不替换**(保持原厂图),而不是拉伸或裁切 ——
|
||||
# 拉伸会把方块变长方形;裁切会切掉按钮两端。宁可保持原厂,也不给设备塞变形图。
|
||||
pw, ph = out_img.size
|
||||
if abs(pw / ph - w / ht) / (w / ht) > 0.005:
|
||||
return None, ('长宽比不一致(源 %dx%d = %.3f,槽位 %dx%d = %.3f)-> 按约定跳过,保持原厂'
|
||||
% (pw, ph, pw / ph, w, ht, w / ht))
|
||||
out_img = out_img.resize((w, ht), RESAMPLE)
|
||||
if cf == CF_ARGB8888 and not (flags & 0x08):
|
||||
blob = stock_blob[:12] + encode_argb8888(out_img)
|
||||
if len(blob) > slot_len:
|
||||
return None, 'ARGB 编码 %d > 槽位 %d' % (len(blob), slot_len)
|
||||
return blob + b'\x00' * (slot_len - len(blob)), 'ARGB(补 %d)' % (slot_len - len(blob))
|
||||
if cf == CF_I8 and (flags & 0x08):
|
||||
method, csize, usize = struct.unpack('<III', stock_blob[12:24])
|
||||
stride = h['stride'] or w
|
||||
best = None
|
||||
for n in (colors,) if colors else RLE_COLOR_STEPS:
|
||||
raw = raw_i8_strided(out_img, stride, n)
|
||||
comp = rle_compress(raw)
|
||||
# 末尾还要补一条「重复 1 次」指令(2 B):原厂流解出来正好是 usize 字节
|
||||
# (= 1024 + stride*h + 1),少这 1 字节设备端解码器可能直接判失败。
|
||||
if len(comp) + 2 <= csize:
|
||||
best = (n, raw, comp)
|
||||
break
|
||||
if best is None:
|
||||
n_last = RLE_COLOR_STEPS[-1]
|
||||
comp = rle_compress(raw_i8_strided(out_img, stride, n_last))
|
||||
return None, 'RLE 压缩后 %d > csize %d(已减到 %d 色)' % (len(comp), csize, n_last)
|
||||
n, raw, comp = best
|
||||
comp = comp + bytes((1, raw[-1])) # 把流做到正好 usize 字节,和原厂一致
|
||||
padded = pad_rle(comp, csize)
|
||||
if padded is None:
|
||||
return None, '补齐失败'
|
||||
blob = stock_blob[:12] + struct.pack('<III', 1, csize, usize) + padded
|
||||
if len(blob) != slot_len:
|
||||
return None, '组装后 %d != 槽位 %d' % (len(blob), slot_len)
|
||||
return blob, 'RLE %d色 %d/%d' % (n, len(comp), csize)
|
||||
return None, 'cf=0x%02x flags=%d 不支持' % (cf, flags)
|
||||
|
||||
|
||||
def targets(r):
|
||||
"""Map a flat, file-name-safe key -> inode for every launcher icon.
|
||||
|
||||
alarm/launcher.bin -> alarm
|
||||
sports/training/launcher.bin -> sports_training
|
||||
phone/contacts_launcher.bin -> phone_contacts
|
||||
"""
|
||||
out = {}
|
||||
for p, i in r.entries():
|
||||
if not p.rsplit('/', 1)[-1].endswith('launcher.bin'):
|
||||
continue
|
||||
stem = p[:-4] # drop '.bin'
|
||||
if stem.endswith('_launcher'):
|
||||
stem = stem[:-len('_launcher')]
|
||||
elif stem.endswith('/launcher'):
|
||||
stem = stem[:-len('/launcher')]
|
||||
out[stem.replace('/', '_')] = i
|
||||
return out
|
||||
|
||||
|
||||
def describe(r, i):
|
||||
h = img_header(r.read(i))
|
||||
if not h:
|
||||
return '?', '?', '?'
|
||||
return f'{h["w"]}x{h["h"]}', FMT.get(h['cf'], f'0x{h["cf"]:02x}'), h
|
||||
|
||||
|
||||
def cmd_list(image):
|
||||
r = Romfs(image)
|
||||
print(f'{os.path.basename(image)} volume={r.volume} romfs_size={r.sb_size}')
|
||||
for app, i in sorted(targets(r).items()):
|
||||
dim, fmt, _ = describe(r, i)
|
||||
print(f' {app:<26} data@0x{i.data_off:08x} len={i.size:<7} {dim} {fmt}')
|
||||
|
||||
|
||||
def cmd_make(image, pngdir, outdir, pad=False, write_patched=False):
|
||||
r = Romfs(image)
|
||||
tg = targets(r)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
payload_dir = os.path.join(outdir, 'payload')
|
||||
os.makedirs(payload_dir, exist_ok=True)
|
||||
patch = bytearray()
|
||||
rollback = bytearray()
|
||||
records, skipped = [], []
|
||||
for fn in sorted(os.listdir(pngdir)):
|
||||
if not fn.lower().endswith('.png'):
|
||||
continue
|
||||
app = fn[:-4]
|
||||
if app not in tg:
|
||||
skipped.append((app, 'no such app'))
|
||||
continue
|
||||
ino = tg[app]
|
||||
orig = r.read(ino)
|
||||
_, fmt, h = describe(r, ino)
|
||||
if h is None:
|
||||
skipped.append((app, f'not LVGL image (len={ino.size})'))
|
||||
continue
|
||||
src = Image.open(os.path.join(pngdir, fn)).convert('RGBA')
|
||||
src_dim = f'{src.size[0]}x{src.size[1]}'
|
||||
resized = False
|
||||
if src.size != (h['w'], h['h']):
|
||||
if pad:
|
||||
tw, th = src.size
|
||||
blob = encode(src, h['cf'], tw, th, h['flags'], pad_to=len(orig))
|
||||
else:
|
||||
src = src.resize((h['w'], h['h']), RESAMPLE)
|
||||
tw, th = src.size
|
||||
resized = True
|
||||
blob = encode(src, h['cf'], tw, th, h['flags'])
|
||||
else:
|
||||
tw, th = src.size
|
||||
blob = encode(src, h['cf'], tw, th, h['flags'])
|
||||
if len(blob) != len(orig):
|
||||
skipped.append((app, f'length {len(blob)} != {len(orig)}'))
|
||||
continue
|
||||
patch += struct.pack('<II', ino.data_off, len(blob)) + blob
|
||||
rollback += struct.pack('<II', ino.data_off, len(orig)) + orig
|
||||
open(os.path.join(payload_dir, app.replace('/', '_') + '.bin'), 'wb').write(blob)
|
||||
records.append(dict(app=app, offset=ino.data_off, length=len(blob),
|
||||
format=fmt, stock_dim=f'{h["w"]}x{h["h"]}',
|
||||
source=fn, source_dim=src_dim,
|
||||
resized=resized, padded=bool(pad and src.size != (h['w'], h['h'])),
|
||||
sha256=hashlib.sha256(blob).hexdigest()))
|
||||
|
||||
open(os.path.join(outdir, 'icons.patch'), 'wb').write(patch)
|
||||
open(os.path.join(outdir, 'icons.rollback'), 'wb').write(rollback)
|
||||
manifest = dict(image=os.path.basename(image), volume=r.volume,
|
||||
image_sha256=hashlib.sha256(r.data).hexdigest(),
|
||||
replace_count=len(records), records=records,
|
||||
skipped=[{'app': a, 'reason': b} for a, b in skipped])
|
||||
json.dump(manifest, open(os.path.join(outdir, 'manifest.json'), 'w'),
|
||||
indent=1, ensure_ascii=False)
|
||||
|
||||
print(f'{"app":<26}{"offset":>12}{"bytes":>8} {"fmt":<10}{"stock":>9}{"source":>9} note')
|
||||
for m in records:
|
||||
note = 'resized' if m['resized'] else ('padded' if m['padded'] else '')
|
||||
print(f'{m["app"]:<26}0x{m["offset"]:08x}{m["length"]:>8} {m["format"]:<10}'
|
||||
f'{m["stock_dim"]:>9}{m["source_dim"]:>9} {note}')
|
||||
for a, why in skipped:
|
||||
print(f' SKIP {a}: {why}')
|
||||
print(f'\n{len(records)} replacement(s)')
|
||||
print(f' payload -> {payload_dir}')
|
||||
print(f' patch -> {os.path.join(outdir, "icons.patch")} ({len(patch)} bytes)')
|
||||
print(f' rollback -> {os.path.join(outdir, "icons.rollback")} ({len(rollback)} bytes)')
|
||||
print(f' manifest -> {os.path.join(outdir, "manifest.json")}')
|
||||
if write_patched:
|
||||
out = os.path.join(outdir, 'vela_app_patched.bin')
|
||||
cmd_apply(image, os.path.join(outdir, 'icons.patch'), out)
|
||||
|
||||
|
||||
def cmd_apply(image, patch, out):
|
||||
data = bytearray(open(image, 'rb').read())
|
||||
blob = open(patch, 'rb').read()
|
||||
off = n = 0
|
||||
while off < len(blob):
|
||||
addr, ln = struct.unpack('<II', blob[off:off+8])
|
||||
chunk = blob[off+8:off+8+ln]
|
||||
assert len(chunk) == ln and addr + ln <= len(data), 'corrupt patch'
|
||||
data[addr:addr+ln] = chunk
|
||||
off += 8 + ln
|
||||
n += 1
|
||||
open(out, 'wb').write(data)
|
||||
print(f'applied {n} record(s) -> {out}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
a = sys.argv[1:]
|
||||
if not a:
|
||||
print(__doc__)
|
||||
elif a[0] == 'list':
|
||||
cmd_list(a[1])
|
||||
elif a[0] == 'make':
|
||||
cmd_make(a[1], a[2], a[3], '--pad' in a, '--patched' in a)
|
||||
elif a[0] == 'apply':
|
||||
cmd_apply(a[1], a[2], a[3])
|
||||
else:
|
||||
print(__doc__)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""在编好的 .face 里定位每一份负载,好让 Lua 运行时按偏移从 resource.bin 里读出来
|
||||
(设备不会把 .face 里的 images/ 解包到文件系统,图片仍然躺在 resource.bin 里)。
|
||||
"""
|
||||
import os, sys, json
|
||||
from paths import PROJ, WORK
|
||||
|
||||
FACE = os.path.join(WORK, 'build', 'S5Icons', 'bin', 'S5Icons.face')
|
||||
PDIR = os.path.join(PROJ, 'payload')
|
||||
RDIR = os.path.join(PROJ, 'payload_rollback')
|
||||
OUT = os.path.join(WORK, 'face_payload_offsets.json')
|
||||
|
||||
face = open(FACE, 'rb').read()
|
||||
print(f'face {os.path.basename(FACE)} {len(face)} bytes')
|
||||
|
||||
anchor = face.find(b'local lvgl = require("lvgl")')
|
||||
print(f'lua source starts at 0x{anchor:x} ({anchor})')
|
||||
|
||||
out = {}
|
||||
for tag, d in (('payload', PDIR), ('rollback', RDIR)):
|
||||
recs = []
|
||||
dup = 0
|
||||
for name in sorted(os.listdir(d)):
|
||||
if not name.endswith('.bin') or name.endswith('payload.lua'):
|
||||
continue
|
||||
data = open(os.path.join(d, name), 'rb').read()
|
||||
if not data:
|
||||
continue
|
||||
hits, start = [], 0
|
||||
while True:
|
||||
i = face.find(data, start)
|
||||
if i < 0:
|
||||
break
|
||||
hits.append(i)
|
||||
start = i + 1
|
||||
if len(hits) > 8:
|
||||
break
|
||||
recs.append((name, len(data), hits, data[:16].hex()))
|
||||
if len(hits) != 1:
|
||||
dup += 1
|
||||
out[tag] = recs
|
||||
print(f'\n{tag}: {len(recs)} files, {dup} with !=1 occurrence')
|
||||
for name, ln, hits, sig in recs[:6]:
|
||||
print(f' {name:<34} {ln:>7} hits={[hex(h) for h in hits]}')
|
||||
if recs:
|
||||
print(' ...')
|
||||
|
||||
json.dump({k: [(n, l, h, s) for n, l, h, s in v] for k, v in out.items()},
|
||||
open(OUT, 'w', encoding='utf-8'), indent=1)
|
||||
print('\nsaved', OUT)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Crude but effective Lua block-balance checker: strips comments and strings,
|
||||
then counts block openers vs `end`."""
|
||||
import sys, re
|
||||
|
||||
def strip(src):
|
||||
out = []
|
||||
i, n = 0, len(src)
|
||||
while i < n:
|
||||
c = src[i]
|
||||
# long bracket comment / string --[[ ]] , --[==[ ]==]
|
||||
m = re.match(r'--\[(=*)\[', src[i:])
|
||||
if m:
|
||||
close = ']' + m.group(1) + ']'
|
||||
j = src.find(close, i)
|
||||
i = n if j < 0 else j + len(close)
|
||||
out.append(' ')
|
||||
continue
|
||||
if src.startswith('--', i):
|
||||
j = src.find('\n', i)
|
||||
i = n if j < 0 else j
|
||||
out.append(' ')
|
||||
continue
|
||||
m = re.match(r'\[(=*)\[', src[i:])
|
||||
if m:
|
||||
close = ']' + m.group(1) + ']'
|
||||
j = src.find(close, i)
|
||||
i = n if j < 0 else j + len(close)
|
||||
out.append('""')
|
||||
continue
|
||||
if c in '"\'':
|
||||
j = i + 1
|
||||
while j < n:
|
||||
if src[j] == '\\':
|
||||
j += 2
|
||||
continue
|
||||
if src[j] == c:
|
||||
break
|
||||
j += 1
|
||||
i = j + 1
|
||||
out.append('""')
|
||||
continue
|
||||
out.append(c)
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def check(path):
|
||||
src = open(path, encoding='utf-8').read()
|
||||
code = strip(src)
|
||||
def cnt(k):
|
||||
return len(re.findall(r'(?<![\w.])' + k + r'(?![\w])', code))
|
||||
# every while/for consumes exactly one `do`, wherever it sits (may wrap lines)
|
||||
do_total = cnt('do')
|
||||
do_standalone = do_total - cnt('while') - cnt('for')
|
||||
openers = cnt('function') + cnt('if') + cnt('while') + cnt('for') + do_standalone
|
||||
enders = cnt('end')
|
||||
repeat_n, until_n = cnt('repeat'), cnt('until')
|
||||
print(f'{path}')
|
||||
print(f' function={cnt("function")} if={cnt("if")} while={cnt("while")} '
|
||||
f'for={cnt("for")} do(standalone)={do_standalone} repeat={repeat_n}')
|
||||
print(f' openers={openers} end={enders} until={until_n}')
|
||||
ok = (openers == enders) and (repeat_n == until_n)
|
||||
print(f' BALANCED: {ok}')
|
||||
if not ok:
|
||||
print(' ^^^ 括号/块不匹配,需要检查')
|
||||
return ok
|
||||
|
||||
|
||||
allok = True
|
||||
for p in sys.argv[1:]:
|
||||
allok &= check(p)
|
||||
print('\nALL OK' if allok else '\nPROBLEM')
|
||||
@@ -0,0 +1,150 @@
|
||||
"""LVGL v9 RLE codec (LV_IMAGE_COMPRESS_RLE).
|
||||
|
||||
Control byte grammar (upstream `lv_rle_decompress`):
|
||||
high bit CLEAR (0x00..0x7F) : repeat the next single `blk`-byte block `ctrl` times
|
||||
high bit SET (0x80|count) : copy `count` blocks verbatim (literal run)
|
||||
count is 1..=127
|
||||
For palette formats (I8) the stream decodes to 256*4 palette bytes followed by
|
||||
w*h palette indices.
|
||||
"""
|
||||
import struct
|
||||
from PIL import Image
|
||||
|
||||
MAGIC = 0x19
|
||||
FLAG_COMPRESSED = 0x08
|
||||
METHOD_NONE, METHOD_RLE, METHOD_LZ4 = 0, 1, 2
|
||||
|
||||
|
||||
def rle_decompress(data, blk, out_len=None):
|
||||
out = bytearray()
|
||||
i, n = 0, len(data)
|
||||
while i < n:
|
||||
ctrl = data[i]; i += 1
|
||||
if ctrl & 0x80:
|
||||
cnt = ctrl & 0x7F
|
||||
nb = cnt * blk
|
||||
if i + nb > n:
|
||||
raise ValueError('truncated literal run')
|
||||
out += data[i:i + nb]
|
||||
i += nb
|
||||
else:
|
||||
cnt = ctrl
|
||||
if i + blk > n:
|
||||
raise ValueError('truncated repeat unit')
|
||||
unit = data[i:i + blk]
|
||||
i += blk
|
||||
out += unit * cnt
|
||||
if out_len is not None and len(out) >= out_len:
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rle_compress(raw, max_run=127):
|
||||
"""LVGL RLE 编码器(rle_decompress 的逆向,blk=1)。
|
||||
|
||||
贪心:先找同字节游程(>=2 用「重复」指令),否则攒「原样」指令。
|
||||
输出长度总是 <= 原图像素流,因为最坏情况每条 literal 只多 1 个控制字节。
|
||||
"""
|
||||
out = bytearray()
|
||||
i, n = 0, len(raw)
|
||||
while i < n:
|
||||
v = raw[i]
|
||||
j = i + 1
|
||||
while j < n and raw[j] == v and j - i < max_run:
|
||||
j += 1
|
||||
run = j - i
|
||||
if run >= 2:
|
||||
out.append(run); out.append(v); i = j
|
||||
continue
|
||||
lit = bytearray()
|
||||
k = i
|
||||
while k < n and len(lit) < max_run:
|
||||
if k + 1 < n and raw[k] == raw[k + 1]:
|
||||
break
|
||||
lit.append(raw[k]); k += 1
|
||||
if not lit: # 理论上到不了这里
|
||||
lit.append(v); k = i + 1
|
||||
out.append(0x80 | len(lit)); out += lit
|
||||
i = k
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def pad_rle(comp, want):
|
||||
"""把 RLE 流补到 want 字节:用 ctrl=0(重复 0 次 = 产出 0 像素)的 2 字节空指令。
|
||||
|
||||
ctrl=0 是合法指令(`len = 0`,两个越界判断都不触发),所以设备端解码器读完 csize
|
||||
字节既不会越界、也不会返回 INVALID —— 这是「等长覆盖」能成立的关键。
|
||||
差值为奇数时先把一条 literal 拆成两条(payload 不变、多 1 个控制字节)。
|
||||
"""
|
||||
d = want - len(comp)
|
||||
if d < 0:
|
||||
return None
|
||||
if d % 2:
|
||||
b = bytearray(comp)
|
||||
for idx in range(len(b) - 1, -1, -1):
|
||||
if b[idx] & 0x80 and (b[idx] & 0x7F) >= 2:
|
||||
cnt = b[idx] & 0x7F
|
||||
lit = bytes(b[idx + 1: idx + 1 + cnt])
|
||||
half = cnt // 2
|
||||
b[idx: idx + 1 + cnt] = (bytes([0x80 | half]) + lit[:half] +
|
||||
bytes([0x80 | (cnt - half)]) + lit[half:])
|
||||
break
|
||||
else:
|
||||
return None
|
||||
comp = bytes(b)
|
||||
d -= 1
|
||||
return comp + b'\x00\x00' * (d // 2)
|
||||
|
||||
|
||||
def parse_header(blob):
|
||||
if len(blob) < 12 or blob[0] != MAGIC:
|
||||
return None
|
||||
magic, cf, flags, w, h, stride, rsv = struct.unpack('<BBHHHHH', blob[:12])
|
||||
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=rsv)
|
||||
|
||||
|
||||
def decode_lvgl_bin(blob):
|
||||
"""Return (header, RGBA PIL image) for uncompressed / RLE LVGL v9 .bin.
|
||||
|
||||
NOTE stride: LVGL pads each row to the header's `stride`, which is NOT always
|
||||
equal to w (e.g. the S5 41mm control-center icons are 116 wide with stride 128).
|
||||
Reading them as w*h contiguous pixels yields rainbow garbage, so the row pitch
|
||||
below is taken from the header.
|
||||
"""
|
||||
h = parse_header(blob)
|
||||
if h is None:
|
||||
return None, None
|
||||
cf, w, ht = h['cf'], h['w'], h['h']
|
||||
body = blob[12:]
|
||||
if h['flags'] & FLAG_COMPRESSED:
|
||||
method, csize, usize = struct.unpack('<III', body[:12])
|
||||
if method != METHOD_RLE:
|
||||
raise ValueError(f'unsupported compression method {method}')
|
||||
blk = 4 if cf == 0x10 else 1 # ARGB8888 是 4 字节一块,I8 是 1 字节
|
||||
raw = rle_decompress(body[12:12 + csize], blk, usize)
|
||||
else:
|
||||
raw = body
|
||||
img = Image.new('RGBA', (w, ht))
|
||||
px = img.load()
|
||||
if cf == 0x10: # ARGB8888 (BGRA)
|
||||
pitch = max(h['stride'] or 0, w * 4)
|
||||
if len(raw) < pitch * ht:
|
||||
return h, None
|
||||
for y in range(ht):
|
||||
row = y * pitch
|
||||
for x in range(w):
|
||||
b, g, r, a = raw[row + x * 4:row + x * 4 + 4]
|
||||
px[x, y] = (r, g, b, a)
|
||||
elif cf == 0x0a: # I8: palette then indices
|
||||
pitch = max(h['stride'] or 0, w)
|
||||
if len(raw) < 1024 + pitch * ht:
|
||||
return h, None
|
||||
pal = raw[:1024]
|
||||
palette = [(pal[i*4+2], pal[i*4+1], pal[i*4+0], pal[i*4+3]) for i in range(256)]
|
||||
for y in range(ht):
|
||||
row = 1024 + y * pitch
|
||||
for x in range(w):
|
||||
px[x, y] = palette[raw[row + x]]
|
||||
else:
|
||||
return h, None
|
||||
return h, img
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Rebuild the replacement payload for the `os.execute` + `dd` route,
|
||||
in a location-independent layout.
|
||||
|
||||
Layout produced:
|
||||
<out>/payload/ NNN_<name>.bin + payload.lua (bare filenames)
|
||||
<out>/payload_rollback/ NNN_<name>.bin + payload.lua
|
||||
<out>/flash.sh linear nsh script (no $( ), no && , no globbing)
|
||||
<out>/rollback.sh
|
||||
<out>/payload.json machine-readable manifest
|
||||
|
||||
All offsets are multiples of 512 and every emitted length is a multiple of 4,
|
||||
so `dd bs=4 seek=offset/4 count=length/4` writes exact ranges with no partial
|
||||
trailing block.
|
||||
"""
|
||||
import os, sys, json, hashlib
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from paths import PROJ, INPUTS, WORK
|
||||
STOCK = os.path.join(INPUTS, 'target', 'vela_app.bin')
|
||||
PATCHED = os.path.join(WORK, 's4_from_s5', 'vela_app_s5icons.bin')
|
||||
OUT = PROJ
|
||||
|
||||
recs_in = json.load(open(os.path.join(WORK, 's4_from_s5', 'manifest.json'), encoding='utf-8'))
|
||||
stock = open(STOCK, 'rb').read()
|
||||
patched = open(PATCHED, 'rb').read()
|
||||
assert len(stock) == len(patched)
|
||||
|
||||
records = [dict(name='canary_superblock', offset=0, length=32,
|
||||
data=stock[:32], changed=True)]
|
||||
assert stock[:8] == b'-rom1fs-'
|
||||
for r in recs_in['records']:
|
||||
off, ln = r['offset'], r['length']
|
||||
records.append(dict(name=r['name'], offset=off, length=ln,
|
||||
data=patched[off:off + ln], changed=stock[off:off + ln] != patched[off:off + ln]))
|
||||
|
||||
emitted = [r for r in records if r['changed']]
|
||||
dropped = [r for r in records if not r['changed']]
|
||||
|
||||
|
||||
def write_pack(dirname, source):
|
||||
d = os.path.join(OUT, dirname)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
for f in os.listdir(d):
|
||||
os.remove(os.path.join(d, f))
|
||||
rows = []
|
||||
for i, r in enumerate(emitted):
|
||||
fn = f'{i:03d}_{r["name"]}.bin'
|
||||
open(os.path.join(d, fn), 'wb').write(source(r))
|
||||
assert r['offset'] % 4 == 0, r['name'] # 偏移恒定 4 对齐(实际是 512)
|
||||
rows.append(dict(name=r['name'], file=fn, offset=r['offset'], length=r['length']))
|
||||
with open(os.path.join(d, 'payload.lua'), 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('-- generated by _tools/make_dd_payload.py -- do not hand-edit\n')
|
||||
f.write('-- dd: -if <this dir>/<file> -of /dev/app bs=4 seek=offset/4 count=length/4\n')
|
||||
f.write('return {\n')
|
||||
for r in rows:
|
||||
f.write(f' {{ name = "{r["name"]}", file = "{r["file"]}", '
|
||||
f'offset = {r["offset"]}, length = {r["length"]} }},\n')
|
||||
f.write('}\n')
|
||||
return rows
|
||||
|
||||
|
||||
lua_rows = write_pack('payload', lambda r: r['data'])
|
||||
r_rows = write_pack('payload_rollback',
|
||||
lambda r: stock[r['offset']:r['offset'] + r['length']])
|
||||
|
||||
# ---------------------------------------------------------------- 合并成「单个条目」
|
||||
# 关键:.face 里**每个文件 = 一个资源条目**。逐条打包会做出 480 个条目,
|
||||
# 把表盘条目数顶到宿主吃不下的量(实测 6.59MB/170 条能跑,9.12MB/490 条黑屏;
|
||||
# 原厂 resource.bin 到 9.43MB 都能跑 -> 卡的是条目数,不是体积)。
|
||||
# 所以给编译用的是一份「魔数 + 所有负载顺序拼接」的单文件,偏移在构建时按相对偏移回填。
|
||||
MAGIC_PAY = b'\x00S4E-PAYv1\x00\xa5\x5a\xc3\x3c' # 15 B,必须全局唯一
|
||||
MAGIC_RB = b'\x00S4E-RBKv1\x00\x5a\xa5\x3c\xc3'
|
||||
BLOB = os.path.join(OUT, 'blob')
|
||||
os.makedirs(BLOB, exist_ok=True)
|
||||
for f in os.listdir(BLOB):
|
||||
os.remove(os.path.join(BLOB, f))
|
||||
|
||||
pay_blob = MAGIC_PAY + b''.join(r['data'] for r in emitted)
|
||||
rb_blob = MAGIC_RB + b''.join(stock[r['offset']:r['offset'] + r['length']] for r in emitted)
|
||||
open(os.path.join(BLOB, 'payload.bin'), 'wb').write(pay_blob)
|
||||
open(os.path.join(BLOB, 'rollback.bin'), 'wb').write(rb_blob)
|
||||
|
||||
rel, off = [], 0
|
||||
for r in emitted:
|
||||
rel.append(dict(name=r['name'], rel=off, target=r['offset'], length=r['length']))
|
||||
off += r['length']
|
||||
json.dump(dict(magic_len=len(MAGIC_PAY), magic_rb_len=len(MAGIC_RB),
|
||||
records=rel, pay_bytes=len(pay_blob), rb_bytes=len(rb_blob)),
|
||||
open(os.path.join(BLOB, 'offsets.json'), 'w', encoding='utf-8'), indent=1)
|
||||
print('blob/ 单条目打包: payload.bin %d B + rollback.bin %d B(%d 条记录,相对偏移表已写)'
|
||||
% (len(pay_blob), len(rb_blob), len(rel)))
|
||||
|
||||
json.dump(dict(device='Xiaomi Watch S4 eSIM (o62lte)', target='/dev/app',
|
||||
dd="dd if=<dir>/<file> of=/dev/app bs=4 seek=<offset/4> count=<length/4>",
|
||||
block_size=4, records=lua_rows,
|
||||
dropped_identical=[r['name'] for r in dropped]),
|
||||
open(os.path.join(OUT, 'payload.json'), 'w'), indent=1, ensure_ascii=False)
|
||||
|
||||
|
||||
def script(fname, rows, header, base, verify, tail):
|
||||
with open(os.path.join(OUT, fname), 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('#!/bin/sh\n')
|
||||
for line in header:
|
||||
f.write('# ' + line + '\n')
|
||||
f.write('\nDEV=/dev/app\nBASE=' + base + '\n\n')
|
||||
f.write('echo "target check"\nls -l $DEV\n\n')
|
||||
for r in rows:
|
||||
# RLE 素材的槽位长度常常不是 4 的倍数 -> 那条退化成 bs=1(偏移本身就对齐)
|
||||
b = 4 if (r['offset'] % 4 == 0 and r['length'] % 4 == 0) else 1
|
||||
f.write('echo "== ' + r['name'] + ' ==\n')
|
||||
f.write(f'dd if=$BASE/{r["file"]} of=$DEV bs={b} seek={r["offset"]//b} '
|
||||
f'count={r["length"]//b}\n')
|
||||
if verify:
|
||||
f.write(f'dd if=$DEV of=/tmp/chk.bin bs={b} skip={r["offset"]//b} '
|
||||
f'count={r["length"]//b}\n')
|
||||
f.write(f'cmp /tmp/chk.bin $BASE/{r["file"]}\n\n')
|
||||
f.write(tail + '\n')
|
||||
|
||||
|
||||
script('flash.sh', lua_rows,
|
||||
['S5 -> S4 icon replacement, run on-device in nsh (NuttX shell).',
|
||||
'把 flash.sh 与 payload/ 放到同一个目录,例如 /data/s5icons/',
|
||||
'然后: sh /data/s5icons/flash.sh 最后: reboot',
|
||||
'nsh 不支持 $( ) / && / || / 通配符,所以这里是线性展开的。'],
|
||||
'/data/s5icons/payload', True,
|
||||
'rm /tmp/chk.bin\necho "all done - run reboot to apply"')
|
||||
|
||||
script('rollback.sh', r_rows,
|
||||
['写回 S4 原厂图标'], '/data/s5icons/payload_rollback', False,
|
||||
'echo "rollback done - run reboot to apply"')
|
||||
|
||||
total = sum(r['length'] for r in lua_rows)
|
||||
print(f'emitted {len(lua_rows)} records, {total} bytes ({total/1048576:.2f} MB)')
|
||||
print(f'dropped {len(dropped)} byte-identical record(s): {[r["name"] for r in dropped]}')
|
||||
for r in lua_rows[:3]:
|
||||
print(f' {r["name"]:<22} off=0x{r["offset"]:08x} len={r["length"]:<7} seek={r["offset"]//4} count={r["length"]//4}')
|
||||
print(' ...')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""为官方 LuaDevTemplate 生成可编译的 overlay —— 本项目只有 S5Icons 一个表盘。"""
|
||||
import os, json, shutil
|
||||
from PIL import Image, ImageFont
|
||||
from paths import PROJ, WORK
|
||||
|
||||
OUT = os.path.join(WORK, 'overlay')
|
||||
ASSETS = os.path.join(PROJ, 'assets')
|
||||
|
||||
DEVICE_TYPE = "362" # 与官方模板一致(362 = S4 家族)
|
||||
ENTRY_WIDGET = "app_lua%2Fmain.lua"
|
||||
PREVIEW_SIZE = 326 # Compiler.exe 对 362 设备强制 326x326
|
||||
SCREEN_SIZE = 466
|
||||
WF_ID = "491552801"
|
||||
FONTS = [r'C:\Windows\Fonts\msyhbd.ttc', r'C:\Windows\Fonts\msyh.ttc',
|
||||
r'C:\Windows\Fonts\arialbd.ttf', r'C:\Windows\Fonts\arial.ttf']
|
||||
|
||||
|
||||
def font(size):
|
||||
for p in FONTS:
|
||||
try:
|
||||
return ImageFont.truetype(p, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def make_preview(path):
|
||||
"""表盘选择页的缩略图:直接用 assets/cover.png 缩到 326x326。"""
|
||||
Image.open(os.path.join(ASSETS, 'cover.png')).convert('RGBA') \
|
||||
.resize((PREVIEW_SIZE, PREVIEW_SIZE), Image.LANCZOS).save(path)
|
||||
|
||||
|
||||
def write_fprj(path, title):
|
||||
xml = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
|
||||
f'<FaceProject DeviceType="{DEVICE_TYPE}">\r\n'
|
||||
f' <Screen Title="{title}" Bitmap="preview.png">\r\n'
|
||||
f' <Widget Shape="34" Name="{ENTRY_WIDGET}" '
|
||||
f'X="0" Y="0" Width="{SCREEN_SIZE}" Height="{SCREEN_SIZE}" Alpha="0" />\r\n'
|
||||
' </Screen>\r\n</FaceProject>\r\n')
|
||||
open(path, 'w', encoding='utf-16', newline='').write(xml)
|
||||
|
||||
|
||||
def build(name):
|
||||
base = os.path.join(OUT, name)
|
||||
if os.path.exists(base):
|
||||
shutil.rmtree(base)
|
||||
fprj = os.path.join(base, 'watchface', 'fprj')
|
||||
os.makedirs(os.path.join(fprj, 'images'), exist_ok=True)
|
||||
os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True)
|
||||
|
||||
cfg = {"projectName": name, "watchfaceId": WF_ID, "power_consumption": "3",
|
||||
"resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE",
|
||||
"input": "watchface/fprj/images/preview.png", "name": "preview"}}
|
||||
json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'),
|
||||
indent=4, ensure_ascii=False)
|
||||
write_fprj(os.path.join(fprj, f'{name}.fprj'), name)
|
||||
make_preview(os.path.join(fprj, 'images', 'preview.png'))
|
||||
shutil.copyfile(os.path.join(PROJ, 'main.lua'),
|
||||
os.path.join(fprj, 'app', 'lua', 'main.lua'))
|
||||
# 只放「合并后的单条目」(blob/) —— .face 里每个文件 = 一个资源条目,
|
||||
# 逐条放会把条目数顶到宿主吃不下的量(见 make_dd_payload.py 注释)。
|
||||
blob = os.path.join(PROJ, 'blob')
|
||||
if not os.path.isdir(blob):
|
||||
raise SystemExit('缺少 blob/ —— 先跑 tools/make_dd_payload.py')
|
||||
for src, rel in ((blob, 'pay'), (os.path.join(PROJ, 'ui_images'), 'ui')):
|
||||
dst = os.path.join(fprj, 'app', 'images', rel)
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
# 逐条负载与回滚仍然保留在 payload/ 与 payload_rollback/(flash.sh、校验脚本用),
|
||||
# 但**不进** .face。
|
||||
return base
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
build('S5Icons')
|
||||
print('overlay written to', os.path.join(OUT, 'S5Icons'))
|
||||
@@ -0,0 +1,898 @@
|
||||
"""S5 -> S4 launcher icons, but matching the STOCK S4 sizing convention.
|
||||
|
||||
Measured difference (why icons looked bigger):
|
||||
stock S4 art : 136x136 inside the 144x144 canvas (outer 4px fully transparent)
|
||||
S5 art : 144x144 full-bleed (outer ring painted)
|
||||
|
||||
So for every replaced slot whose source art is full-bleed we scale it to
|
||||
136x136 and centre it in the same 144x144 canvas, then re-encode into the S4
|
||||
slot's exact colour format. Every emitted blob keeps the slot's byte length, so
|
||||
ROMFS absolute offsets stay valid (a length change would corrupt the image).
|
||||
|
||||
Slots whose S5 counterpart is already inset (== stock) are still copied
|
||||
verbatim, so identical icons stay bit-exact.
|
||||
"""
|
||||
import os, sys, json, shutil, hashlib, struct
|
||||
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
|
||||
S4 = os.path.join(INPUTS, 'target', 'vela_app.bin')
|
||||
S5_IMAGES = [os.path.join(INPUTS, 'source', 'vela_app.bin'),
|
||||
os.path.join(INPUTS, 'source', 'vela_health.bin')]
|
||||
OUTDIR = os.path.join(WORK, 's4_from_s5')
|
||||
PATCHED = os.path.join(OUTDIR, 'vela_app_s5icons.bin')
|
||||
MARGIN = 4
|
||||
|
||||
# 出「分组表盘」用:ICON_SKIP_WALKIE=1 -> 只出图标(体积回到 6.58MB 那档)
|
||||
# ICON_ONLY_WALKIE=1 -> 只出对讲机素材(小表盘)
|
||||
# 对讲机素材:**默认不打包**(用户 2026-10-02 决定不改)。ICON_WALKIE=1 可打开。
|
||||
DO_WALKIE = os.environ.get('ICON_WALKIE') == '1'
|
||||
SKIP_WALKIE = (os.environ.get('ICON_SKIP_WALKIE') == '1') or not DO_WALKIE
|
||||
ONLY_WALKIE = os.environ.get('ICON_ONLY_WALKIE') == '1'
|
||||
if not DO_WALKIE:
|
||||
print('!! 对讲机素材默认不打包(ICON_WALKIE=1 可打开)')
|
||||
if SKIP_WALKIE:
|
||||
print('!! ICON_SKIP_WALKIE=1:本次不打包对讲机素材')
|
||||
if ONLY_WALKIE:
|
||||
print('!! ICON_ONLY_WALKIE=1:本次只打包对讲机素材')
|
||||
|
||||
# Slots the user hand-replaced with their own PNGs. These win over the S5
|
||||
# firmware source and always go through normalization (crop to the painted area,
|
||||
# scale to the slot's own painted size, centre) so the emitted blob keeps the
|
||||
# slot's exact byte length:
|
||||
# activities / sports_course / interconnect : 112x112 self-made art
|
||||
# voice_aivs : 144x144 "小爱同学" user PNG
|
||||
OVERRIDE = {k: os.path.join(PROJ, 'assets', 'override', k + '.png')
|
||||
for k in ('activities', 'sports_course', 'interconnect', 'voice_aivs')}
|
||||
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
if os.path.exists(PATCHED):
|
||||
bak = os.path.join(OUTDIR, 'vela_app_s5icons_fullbleed_backup.bin')
|
||||
if not os.path.exists(bak):
|
||||
shutil.copyfile(PATCHED, bak)
|
||||
print(f'backed up previous (full-bleed) image -> {os.path.basename(bak)}')
|
||||
|
||||
|
||||
def ink_bbox(img, thr=16):
|
||||
"""bounding box of painted pixels (alpha > thr), or None"""
|
||||
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
|
||||
if x1 < 0:
|
||||
return None
|
||||
return (x0, y0, x1, y1)
|
||||
|
||||
|
||||
def normalize(img, target, canvas_wh):
|
||||
"""Scale the painted area so its larger side becomes `target`, keeping the
|
||||
aspect ratio, and centre it on a transparent canvas of the SLOT size.
|
||||
|
||||
canvas_wh is the slot's own w,h -- the source may be any size (the S5 art is
|
||||
144x144, the Band 11 art the user dropped in is 112x112), so the output
|
||||
canvas must not be taken from the source.
|
||||
"""
|
||||
cw, ch = canvas_wh
|
||||
bb = ink_bbox(img)
|
||||
if bb is None:
|
||||
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
x0, y0, x1, y1 = bb
|
||||
bw, bh = x1 - x0 + 1, y1 - y0 + 1
|
||||
s = float(target) / float(max(bw, bh))
|
||||
if abs(s - 1.0) < 1e-6 and (bw, bh) == (cw, ch):
|
||||
return img.convert('RGBA')
|
||||
art = img.convert('RGBA').crop((x0, y0, x1 + 1, y1 + 1))
|
||||
nw, nh = max(1, int(round(bw * s))), max(1, int(round(bh * s)))
|
||||
art = art.resize((nw, nh), Image.LANCZOS)
|
||||
if s > 1.05:
|
||||
# upsampling always softens; give the edges some bite back
|
||||
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
|
||||
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
canvas.paste(art, ((cw - nw) // 2, (ch - nh) // 2))
|
||||
return canvas
|
||||
|
||||
|
||||
s4 = Romfs(S4)
|
||||
s4t = ip.targets(s4)
|
||||
|
||||
s5 = {}
|
||||
S5ROM = []
|
||||
for path in S5_IMAGES:
|
||||
r = Romfs(path)
|
||||
S5ROM.append(r)
|
||||
tag = r.volume
|
||||
for key, ino in ip.targets(r).items():
|
||||
if key in s5:
|
||||
continue
|
||||
blob = r.read(ino)
|
||||
_, img = decode_lvgl_bin(blob)
|
||||
hh = img_header(blob)
|
||||
# an I8 source ships its own 256-entry palette: reuse it verbatim
|
||||
pal = blob[12:12 + 1024] if (hh and hh['cf'] == 0x0a and len(blob) >= 1036) else None
|
||||
s5[key] = (blob, img, tag, ino.size, pal)
|
||||
|
||||
print(f'S4 slots: {len(s4t)} S5 icons: {len(s5)}\n')
|
||||
|
||||
plan, skipped, failed, new_blobs, extra_ino = [], [], [], {}, {}
|
||||
for key, ino in sorted(s4t.items()):
|
||||
if ONLY_WALKIE:
|
||||
break
|
||||
if key not in s5:
|
||||
skipped.append((key, 'no S5 counterpart'))
|
||||
continue
|
||||
h4 = img_header(s4.read(ino))
|
||||
blob5, img5, tag5, size5, pal5 = s5[key]
|
||||
slot_len = ino.size
|
||||
how = None
|
||||
|
||||
# a hand-replaced PNG wins over the firmware source
|
||||
ov = OVERRIDE.get(key)
|
||||
if ov and os.path.exists(ov):
|
||||
img5 = Image.open(ov).convert('RGBA')
|
||||
blob5 = b'' # force re-encode, never verbatim copy
|
||||
pal5 = None # the S5 palette does not describe THIS art
|
||||
tag5 = 'user-png %dx%d' % img5.size
|
||||
|
||||
# what sizing does THIS slot use in the stock image?
|
||||
_, stock_img = decode_lvgl_bin(s4.read(ino))
|
||||
sb = ink_bbox(stock_img) if stock_img is not None else None
|
||||
target = max(sb[2] - sb[0] + 1, sb[3] - sb[1] + 1) if sb else 136
|
||||
|
||||
nb = ink_bbox(img5) if img5 is not None else None
|
||||
need = nb is not None and (nb[2] - nb[0] + 1, nb[3] - nb[1] + 1) != (target, target)
|
||||
if need:
|
||||
src = normalize(img5, target, (h4["w"], h4["h"]))
|
||||
if src.size != (h4['w'], h4['h']):
|
||||
src = src.resize((h4['w'], h4['h']), Image.LANCZOS)
|
||||
try:
|
||||
out = ip.encode(src, h4['cf'], h4['w'], h4['h'], h4['flags'], pad_to=slot_len, palette=pal5)
|
||||
except ValueError:
|
||||
out = None # slot is RLE-compressed: an uncompressed encode
|
||||
# cannot fit, so fall through to the copy path
|
||||
if out is not None and len(out) == slot_len:
|
||||
how = 'normalize->%d' % target
|
||||
else:
|
||||
out = None
|
||||
if how is None:
|
||||
if len(blob5) == slot_len:
|
||||
out, how = blob5, 'copy'
|
||||
elif img5 is None:
|
||||
failed.append((key, 'S5 source not decodable'))
|
||||
continue
|
||||
else:
|
||||
src = img5
|
||||
if src.size != (h4['w'], h4['h']):
|
||||
src = src.resize((h4['w'], h4['h']), Image.LANCZOS)
|
||||
out = ip.encode(src, h4['cf'], h4['w'], h4['h'], h4['flags'], palette=pal5)
|
||||
if len(out) != slot_len:
|
||||
failed.append((key, f'encoded {len(out)} != slot {slot_len}'))
|
||||
continue
|
||||
how = 're-encode'
|
||||
|
||||
plan.append(dict(name=key, offset=ino.data_off, length=slot_len,
|
||||
s4_format=ip.FMT.get(h4['cf'], f"0x{h4['cf']:02x}"),
|
||||
s4_flags=h4['flags'], s5_bytes=len(blob5), s5_from=tag5,
|
||||
method=how))
|
||||
new_blobs[key] = out
|
||||
|
||||
# ---------------------------------------------------------------- 控制中心磁贴(q63 -> S4 eSIM)
|
||||
# S4 eSIM / S5 eSIM 的控制中心是「蓝色主题」(选中 = 实心蓝盘,未选中 = 深色盘);
|
||||
# q63(S5 41mm / OS4)是「浅色主题」(选中 = 白盘 + 蓝字形)。整批一起搬才协调。
|
||||
CC_DIR = 'control_center/icon/'
|
||||
# 除了整目录的控制中心磁贴,再点名搬这几张系统 UI 素材(和 S5 eSIM 那份一致)。
|
||||
# 健康分区的两条(notification_stand / notification_vigor)本来就和 q63 逐字节相同,
|
||||
# 而且 S4 这边只解出了 app 分区,所以列在这里也会被跳过。
|
||||
SYS_PATHS = [
|
||||
'system/icon/lock_icon.bin',
|
||||
'common/icon/system_lock.bin',
|
||||
'common/icon/lock.bin',
|
||||
'system/icon/unlock.bin',
|
||||
'common/icon/quiet_mode.bin',
|
||||
'common/icon/unread_msg.bin',
|
||||
'notifications/default.bin',
|
||||
'notifications/default_small.bin',
|
||||
'notifications/no_message.bin',
|
||||
'settings/icon/notify.bin',
|
||||
'settings/disturb/confirm.bin',
|
||||
'esimsms/icon/esim_sms_reminder.bin',
|
||||
'esimsms/icon/sms_no_read.bin',
|
||||
'esimsms/icon/sms_reminder_watch.bin',
|
||||
'phone/icon/send_sms_new.bin',
|
||||
'mijia/enable_donot_disturb.bin',
|
||||
'mijia/disable_donot_disturb.bin',
|
||||
# 压力 App 的小表情(S4 没有 health 分区,这些都在 /dev/app;q63 那份在它的 health 里)
|
||||
'pressure/icon/presure_relax.bin',
|
||||
'pressure/icon/presure_mild.bin',
|
||||
'pressure/icon/presure_mid.bin',
|
||||
'pressure/icon/presure_severe.bin',
|
||||
'pressure/icon/presure_relax32.bin',
|
||||
'pressure/icon/presure_mild32.bin',
|
||||
'pressure/icon/presure_mid32.bin',
|
||||
'pressure/icon/presure_severe32.bin',
|
||||
'pressure/icon/presure_relax96.bin',
|
||||
'pressure/icon/presure_mild96.bin',
|
||||
'pressure/icon/presure_mid96.bin',
|
||||
'pressure/icon/presure_severe96.bin',
|
||||
'pressure/icon/presure_icon.bin',
|
||||
'pressure/icon/press_grade1.bin',
|
||||
'pressure/icon/press_grade2.bin',
|
||||
'pressure/icon/press_grade3.bin',
|
||||
'pressure/icon/press_grade4.bin',
|
||||
'pressure/icon/pressure_eomji_bg.bin',
|
||||
'pressure/icon/pressure_eomji_widbg.bin',
|
||||
]
|
||||
s4_all = {p: i for p, i in s4.entries() if i.type != 1}
|
||||
s5_all = {}
|
||||
for _r in S5ROM:
|
||||
for _p, _i in _r.entries():
|
||||
if _i.type != 1 and _p not in s5_all:
|
||||
s5_all[_p] = (_r, _i)
|
||||
|
||||
print('\n--- 控制中心磁贴 + 系统 UI 素材(q63 -> S4 eSIM)---')
|
||||
cc_paths = set()
|
||||
_all_paths = sorted(p for p in s4_all if p.startswith(CC_DIR) and p.endswith('.bin'))
|
||||
for _sp in SYS_PATHS:
|
||||
if _sp in s4_all and _sp not in _all_paths:
|
||||
_all_paths.append(_sp)
|
||||
if ONLY_WALKIE:
|
||||
_all_paths = []
|
||||
for path in _all_paths:
|
||||
tino = s4_all[path]
|
||||
got = s5_all.get(path)
|
||||
if got is None:
|
||||
skipped.append((path, 'q63 没有这个文件')); continue
|
||||
qrom, oino = got
|
||||
tgt_blob = s4.read(tino)
|
||||
src_blob = qrom.read(oino)
|
||||
if src_blob == tgt_blob:
|
||||
skipped.append((path, '两边字节完全相同')); continue
|
||||
th = img_header(tgt_blob) if len(tgt_blob) >= 12 else None
|
||||
sh = img_header(src_blob) if len(src_blob) >= 12 else None
|
||||
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, '无法解码(cf=0x%02x)' % (th['cf'] if th else 0))); continue
|
||||
sb = ink_bbox(timg)
|
||||
nb = ink_bbox(simg)
|
||||
if sb and nb:
|
||||
tw, thh = sb[2] - sb[0] + 1, sb[3] - sb[1] + 1
|
||||
art = simg.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
|
||||
if art.size != (tw, thh):
|
||||
up = tw > art.size[0] or thh > art.size[1]
|
||||
art = art.resize((tw, thh), Image.LANCZOS)
|
||||
if up:
|
||||
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
|
||||
canvas = Image.new('RGBA', (th['w'], th['h']), (0, 0, 0, 0))
|
||||
canvas.paste(art, (sb[0], sb[1]))
|
||||
out = canvas
|
||||
else:
|
||||
out = simg
|
||||
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 = 'rescale %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
|
||||
else:
|
||||
skipped.append((path, '长度/格式不支持')); continue
|
||||
if len(new) != len(tgt_blob):
|
||||
failed.append((path, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
|
||||
is_cc = path.startswith(CC_DIR)
|
||||
name = ('cc_' if is_cc else 'sys_') + path.split('/')[-1][:-4]
|
||||
new_blobs[name] = new
|
||||
extra_ino[name] = tino
|
||||
cc_paths.add(path)
|
||||
plan.append(dict(name=name, kind=('cc' if is_cc else 'sysicon'), path=path, offset=tino.data_off, length=len(tgt_blob),
|
||||
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']) if th else '?',
|
||||
s4_flags=th['flags'] if th else 0, s5_bytes=len(src_blob),
|
||||
s5_from='q63 %s' % os.path.basename(qrom.path), method=how))
|
||||
print(' %-46s %-22s %s' % (path, how, ''))
|
||||
|
||||
|
||||
# ------------------------------------------------- 对讲机素材(用户提供的整套 PNG)
|
||||
# walkie_talkie/ 下的槽位几乎**全是 I8+RLE 压缩**的:直接编码长度会变(ROMFS 偏移全平移),
|
||||
# 所以走 ip.encode_into_slot():量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize,
|
||||
# 保证与原槽位逐字节等长。
|
||||
WALKIE_SRC = os.path.join(PROJ, 'assets', 'walkie_talkie')
|
||||
walk_paths = set()
|
||||
mijia_paths = set()
|
||||
print('\n--- 对讲机素材 walkie_talkie(用户 PNG)-> S4 eSIM ---')
|
||||
walkie_n = 0
|
||||
if SKIP_WALKIE or not os.path.isdir(WALKIE_SRC):
|
||||
print(' 跳过这一组(ICON_SKIP_WALKIE=%s / 目录存在=%s)'
|
||||
% (SKIP_WALKIE, os.path.isdir(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'
|
||||
tino = s4_all.get(path)
|
||||
if tino is None:
|
||||
skipped.append((path, '目标固件没有这个槽位')); continue
|
||||
tgt_blob = s4.read(tino)
|
||||
img = Image.open(os.path.join(WALKIE_SRC, rel))
|
||||
new, how = ip.encode_into_slot(img, tgt_blob, tino.size)
|
||||
if new is None:
|
||||
skipped.append((path, how)); continue
|
||||
if new == tgt_blob:
|
||||
skipped.append((path, '与现有一致')); continue
|
||||
th = img_header(tgt_blob)
|
||||
name = 'walk_' + rel[:-4].replace('/', '_')
|
||||
new_blobs[name] = new
|
||||
extra_ino[name] = tino
|
||||
walk_paths.add(path)
|
||||
plan.append(dict(name=name, kind='walkie', path=path, offset=tino.data_off,
|
||||
length=tino.size,
|
||||
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']) if th else '?',
|
||||
s4_flags=th['flags'] if th else 0, s5_bytes=0,
|
||||
s5_from='用户 PNG assets/walkie_talkie/' + rel, method=how))
|
||||
walkie_n += 1
|
||||
if walkie_n <= 6 or walkie_n % 40 == 0:
|
||||
print(' %-52s %-18s %d B' % (path, how, tino.size))
|
||||
print(' 对讲机素材写入 %d 条' % walkie_n)
|
||||
|
||||
# ------------------------------------------------- 控制中心顶部状态区(q63 -> S4 eSIM)
|
||||
# q63 的顶栏素材在 control_center/bar/:wifi 4 档 / 蜂窝信号 5 档 / 3G·4G 文字 /
|
||||
# 蓝牙连接·断开 / 飞行模式 / 定位 / 勿扰 / 静音。本机同名槽位在 control_center/icon/(48x48 I8)。
|
||||
# 尺寸不同的(q63 那几张 36x36)按**实心范围**归一化:把 q63 的实心图缩放到本机槽位的实心范围、
|
||||
# 贴在同样的原点 —— 位置与视觉重量和本机原有图标一致。
|
||||
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_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'),
|
||||
]
|
||||
# 本机把「3G/4G 文字 + 信号格」合成在一张图里,而 q63 是分开的两张 —— 按本机原有的两段实心区域
|
||||
# 分别填:左边文字段用 q63 的 3G/4G,右边信号段用 q63 的 signal_strengthN。
|
||||
CC_COMPOSED = []
|
||||
for _net in ('3g', '4g'):
|
||||
for _n in range(1, 5):
|
||||
CC_COMPOSED.append(('control_center/icon/network_%s_signal_%d.bin' % (_net, _n),
|
||||
'control_center/bar/network_%s.bin' % _net,
|
||||
'control_center/bar/signal_strength%d.bin' % _n))
|
||||
CC_COMPOSED.append(('control_center/icon/network_%s_no_signal.bin' % _net,
|
||||
'control_center/bar/network_%s.bin' % _net,
|
||||
'control_center/bar/signal_strength0.bin'))
|
||||
# 手机电量(本机 phone/icon/battery_level_*):q63 同名,只是高度不同
|
||||
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))
|
||||
|
||||
|
||||
def _split_two(img):
|
||||
"""把「文字 + 信号格」这类合成图按空列切成左右两段,返回两段 bbox(失败返回 None)。"""
|
||||
a = img.convert('RGBA').getchannel('A')
|
||||
w, h = a.size
|
||||
px = a.load()
|
||||
cols = [any(px[x, y] > 16 for y in range(h)) for x in range(w)]
|
||||
gaps, run = [], None
|
||||
for x, on in enumerate(cols):
|
||||
if not on:
|
||||
run = x if run is None else run
|
||||
else:
|
||||
# 只认「两侧都有实心」的空隙:开头的空白段(0..n)不是切分点
|
||||
if run is not None and run > 0 and x - run >= 1:
|
||||
gaps.append((run, x - 1))
|
||||
run = None
|
||||
if not gaps:
|
||||
return None
|
||||
# 取**最左边**那个「两侧都有实心」的空隙 = 文字与信号格之间的分界
|
||||
# (取最宽的会落在信号格之间,把格数挤进一条缝里)
|
||||
g0, g1 = gaps[0]
|
||||
left = [x for x in range(0, g0) if cols[x]]
|
||||
right = [x for x in range(g1 + 1, w) if cols[x]]
|
||||
if not left or not right:
|
||||
return None
|
||||
|
||||
def bbox(xs):
|
||||
ys = [y for y in range(h) for x in xs if px[x, y] > 16]
|
||||
return (min(xs), min(ys), max(xs), max(ys))
|
||||
return bbox(left), bbox(right)
|
||||
|
||||
|
||||
def normalize_to_bbox(src_img, target_bb, canvas_wh, fit=False):
|
||||
"""把 src 的**实心区域**放进 target_bb。
|
||||
|
||||
fit=False:拉伸填满 target_bb(槽位画法本来就一致时用)
|
||||
fit=True :**等比**缩放到能放进 target_bb 的最大尺寸并居中(不拉伸 —— 合成图里
|
||||
「信号格」这类会因横纵比例不同被压扁,必须走这条)
|
||||
"""
|
||||
cw, ch = canvas_wh
|
||||
if target_bb is None:
|
||||
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
sb = ink_bbox(src_img)
|
||||
if sb is None:
|
||||
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
tw, thh = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
|
||||
art = src_img.convert('RGBA').crop((sb[0], sb[1], sb[2] + 1, sb[3] + 1))
|
||||
if fit:
|
||||
s = min(tw / art.size[0], thh / 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))
|
||||
pos = (target_bb[0] + (tw - nw) // 2, target_bb[1] + (thh - nh) // 2)
|
||||
else:
|
||||
if art.size != (tw, thh):
|
||||
up = tw > art.size[0] or thh > art.size[1]
|
||||
art = art.resize((tw, thh), Image.LANCZOS)
|
||||
if up:
|
||||
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
|
||||
pos = (target_bb[0], target_bb[1])
|
||||
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
canvas.paste(art, pos)
|
||||
return canvas
|
||||
|
||||
|
||||
print('\n--- 控制中心顶部状态区(q63 control_center/bar -> S4 eSIM)---')
|
||||
cc_top_n = 0
|
||||
for tpath, spath, note in CC_TOP:
|
||||
tino = s4_all.get(tpath)
|
||||
got = s5_all.get(spath)
|
||||
if tino is None:
|
||||
skipped.append((tpath, '本机没有这个槽位')); continue
|
||||
if got is None:
|
||||
skipped.append((spath, 'q63 没有这个素材')); continue
|
||||
qrom, oino = got
|
||||
tgt_blob = s4.read(tino)
|
||||
src_blob = qrom.read(oino)
|
||||
th = img_header(tgt_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
|
||||
sb = ink_bbox(timg)
|
||||
out = normalize_to_bbox(simg, sb, (th['w'], th['h']))
|
||||
pal = src_blob[12:12 + 1024] if img_header(src_blob)['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
|
||||
# 与 sys 组用同名 -> 同一槽位只保留一条记录(后者覆盖前者)
|
||||
name = ('cc_' if tpath.startswith(CC_DIR) else 'sys_') + tpath.split('/')[-1][:-4]
|
||||
new_blobs[name] = new
|
||||
extra_ino[name] = tino
|
||||
cc_paths.add(tpath)
|
||||
plan.append(dict(name=name, kind='cctop', path=tpath, offset=tino.data_off, length=tino.size,
|
||||
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
|
||||
s5_bytes=len(src_blob), s5_from='q63 %s' % spath, method='归一化(%s)' % note))
|
||||
cc_top_n += 1
|
||||
print(' %-46s <- %-40s %s' % (tpath, spath, note))
|
||||
print(' 顶栏状态区写入 %d 条' % cc_top_n)
|
||||
|
||||
print('\n--- 控制中心「3G/4G+信号格」合成图(S4e 的 network_* + S5e 的 signal_strength)---')
|
||||
# 合成来源按要求改:文字取 **本机(S4e)** 的 network_3g/4g,信号格取 **S5e** 的 signal_strength*
|
||||
# (S5e 表盘里的当前版本,若它已被移植过就是新的蓝色版;没有就用它的原厂版)
|
||||
S5E_IMG = os.path.join(PROJ, '..', 's5e-icons', '.work', 's5e_from_os4', 'vela_app_os4.bin')
|
||||
_s5e = Romfs(S5E_IMG) if os.path.exists(S5E_IMG) else None
|
||||
_s5e_tab = {q: i for q, i in _s5e.entries()} if _s5e else {}
|
||||
print(' S5e 信号格来源: %s' % ('S5e 表盘镜像' if _s5e else '(缺,回退 q63)'))
|
||||
cc_comp_n = 0
|
||||
for tpath, tpath_text, tpath_bars in CC_COMPOSED:
|
||||
tino = s4_all.get(tpath)
|
||||
gt = s5_all.get(tpath_text)
|
||||
gb = s5_all.get(tpath_bars)
|
||||
if tino is None or gt is None or gb is None:
|
||||
skipped.append((tpath, '缺槽位或 q63 缺素材')); continue
|
||||
tgt_blob = s4.read(tino)
|
||||
th = img_header(tgt_blob)
|
||||
timg = decode_lvgl_bin(tgt_blob)[1]
|
||||
if timg is None:
|
||||
skipped.append((tpath, '本机图解码失败')); continue
|
||||
parts = _split_two(timg)
|
||||
if parts is None:
|
||||
skipped.append((tpath, '切不开两段(布局不同)')); continue
|
||||
(tx0, ty0, tx1, ty1), (bx0, by0, bx1, by1) = parts
|
||||
canvas = Image.new('RGBA', (th['w'], th['h']), (0, 0, 0, 0))
|
||||
pal = None
|
||||
ow = th['w'] - 4 # 用满整张宽度(左右各留 2px)
|
||||
x0 = 2
|
||||
gap = 3
|
||||
tw_new = max(10, round(ow * 0.44))
|
||||
bw_new = max(10, ow - tw_new - gap)
|
||||
text_bb = (x0, 0, x0 + tw_new - 1, th['h'] - 1)
|
||||
bars_x0 = x0 + tw_new + gap
|
||||
bars_bb = (bars_x0, 0, bars_x0 + bw_new - 1, th['h'] - 1)
|
||||
|
||||
# 文字:优先用**本次刚写好的本机 network_3g/4g**(就是 S4e 上的那张)
|
||||
tname = ('cc_' if tpath_text.startswith(CC_DIR) else 'sys_') + tpath_text.split('/')[-1][:-4]
|
||||
text_img = decode_lvgl_bin(new_blobs[tname])[1] if tname in new_blobs else None
|
||||
if text_img is None:
|
||||
text_img = decode_lvgl_bin(gt[0].read(gt[1]))[1]
|
||||
canvas.alpha_composite(normalize_to_bbox(text_img, text_bb, (th['w'], th['h']), fit=True))
|
||||
pal = new_blobs[tname][12:12 + 1024] if tname in new_blobs else None
|
||||
|
||||
# 信号格:**S5e** 的同名槽位(signal_strength / signal_strength1..4)
|
||||
lvl = tpath_bars.split('signal_strength')[-1][:-4] # '', '1'...'4'
|
||||
s5e_name = 'control_center/icon/signal_strength%s.bin' % lvl
|
||||
bars_img = None
|
||||
if _s5e is not None and s5e_name in _s5e_tab:
|
||||
bars_img = decode_lvgl_bin(_s5e.read(_s5e_tab[s5e_name]))[1]
|
||||
if bars_img is None:
|
||||
bars_img = decode_lvgl_bin(gb[0].read(gb[1]))[1]
|
||||
if bars_img is None:
|
||||
skipped.append((tpath, '信号格来源解码失败')); continue
|
||||
canvas.alpha_composite(normalize_to_bbox(bars_img, bars_bb, (th['w'], th['h']), fit=True))
|
||||
|
||||
new = ip.encode(canvas, 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
|
||||
# 与 sys 组用同名 -> 同一槽位只保留一条记录(后者覆盖前者)
|
||||
name = ('cc_' if tpath.startswith(CC_DIR) else 'sys_') + tpath.split('/')[-1][:-4]
|
||||
new_blobs[name] = new
|
||||
extra_ino[name] = tino
|
||||
cc_paths.add(tpath)
|
||||
plan.append(dict(name=name, kind='cctop', path=tpath, offset=tino.data_off, length=tino.size,
|
||||
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
|
||||
s5_bytes=0, s5_from='S4e %s + S5e %s' % (tpath_text, s5e_name),
|
||||
method='合成(文字+信号格)'))
|
||||
cc_comp_n += 1
|
||||
print(' %-46s <- S4e %s + S5e %s' % (tpath, tpath_text.split('/')[-1], s5e_name.split('/')[-1]))
|
||||
print(' 合成图写入 %d 条' % cc_comp_n)
|
||||
|
||||
# ------------------------------------------------- 米家 / 融合中心(控制中心里的米家设备图标)
|
||||
# 规则:q63 的 fusion_center/** 与 mijia/** 里,**同名路径**在本机也有的就移植
|
||||
# (S5e 还有一份 fusion_center/gauss/**,同名的一并写)。画布尺寸两边常常不同
|
||||
# (q63 是 116/64,本机是 96/128),所以按**实心范围**对齐 + 转成本机槽位的格式。
|
||||
PLATE_KEEP = ('headset',) # 只有这种「裸图案」槽位保留底版 + 图案归位
|
||||
# q63 的「柔光玻璃」底盘(干净的深色玻璃圆盘 + 发丝高光环)—— 用它当底版
|
||||
_glass = None
|
||||
_g = s5_all.get('fusion_center/fg.bin')
|
||||
if _g is not None:
|
||||
_glass = decode_lvgl_bin(_g[0].read(_g[1]))[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/')
|
||||
# 控制中心的米家/融合中心那组:**默认不打包**(用户 2026-10-02 决定不改)。ICON_MIJIA=1 可打开。
|
||||
DO_MIJIA = os.environ.get('ICON_MIJIA') == '1'
|
||||
def plate_glyph_bbox(img, tol=26, min_plate=0.30):
|
||||
"""目标图标里「底版上的图案」的 bbox。
|
||||
|
||||
本机很多设备图标是「深色圆底版 + 亮色图案」;q63 有的素材是**裸图案(没有底版)**,
|
||||
这时按实心范围缩放会把图案放大到整个底版 —— 必须改成:保留底版,把裸图案放回
|
||||
原图案的位置/尺寸。做法:底版色 = 出现最多的颜色,其余像素的 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):
|
||||
"""实心范围占画布的比例(两个方向取大者)—— 接近 1.0 说明是"满幅/带底版"的图。"""
|
||||
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 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])
|
||||
# 把图案等比放进 target_bb 后的实际位置/尺寸
|
||||
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 -> S4 eSIM)%s ---' % ('' if DO_MIJIA else ':已按设置跳过'))
|
||||
mijia_n = 0
|
||||
for _q in sorted(s5_all):
|
||||
if not DO_MIJIA or not _q.startswith(MIJIA_PREFIX):
|
||||
continue
|
||||
if _q.endswith('launcher.bin'):
|
||||
continue # 应用图标由 launcher 组处理(那边处理 ARGB 画法)
|
||||
_got = s5_all[_q]
|
||||
if _got[0] is None:
|
||||
continue
|
||||
qrom, oino = _got
|
||||
cands = [_q]
|
||||
if _q.startswith('fusion_center/icon/'):
|
||||
cands.append(_q.replace('fusion_center/icon/', 'fusion_center/gauss/'))
|
||||
for tpath in cands:
|
||||
tino = s4_all.get(tpath)
|
||||
if tino is None:
|
||||
if tpath == _q:
|
||||
skipped.append((tpath, '本机没有这个槽位'))
|
||||
continue
|
||||
tgt_blob = s4.read(tino)
|
||||
src_blob = qrom.read(oino)
|
||||
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
|
||||
how = None
|
||||
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
|
||||
new, how = ip.encode_into_slot(simg, tgt_blob, tino.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)
|
||||
# 只对「裸图案」的槽位用这个模式(本机是底版+图案、q63 是裸图案)。
|
||||
# 底版直接用 **q63 那张柔光玻璃盘**(fusion_center/fg.bin),而不是本机原来的平底,
|
||||
# 这样移植过去的图标看着就是 S5 41mm 的玻璃质感。
|
||||
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, '索引层合成不可用(非未压缩 I8)')); continue
|
||||
out, how = None, '底版原样+图案入索引 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
|
||||
else:
|
||||
out = normalize_to_bbox(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
|
||||
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
|
||||
if new is None or len(new) != tino.size:
|
||||
failed.append((tpath, '编码失败/长度不符 (%s)' % how)); continue
|
||||
if new == tgt_blob:
|
||||
skipped.append((tpath, '两边已经一致')); continue
|
||||
name = 'sys_' + tpath.replace('/', '_')[:-4]
|
||||
new_blobs[name] = new
|
||||
extra_ino[name] = tino
|
||||
mijia_paths.add(tpath)
|
||||
plan.append(dict(name=name, kind='mijia', path=tpath, offset=tino.data_off, length=tino.size,
|
||||
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
|
||||
s5_bytes=len(src_blob), s5_from='q63 %s' % _q, method=how))
|
||||
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/** -> 本机)
|
||||
# 同名路径对齐移植;尺寸不同的按实心范围归一化,格式不同自动转换(RLE / ARGB / I8)。
|
||||
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 组」管(那边处理 I8↔ARGB 与留边画法),这里跳过避免重复写同一槽位
|
||||
PORT_SKIP_TAIL = ('launcher.bin', 'launcher')
|
||||
print('\n--- 通知/电话/录音机素材(q63 同名移植 -> S4 eSIM)---')
|
||||
notif_n = 0
|
||||
for _q in sorted(s5_all):
|
||||
if not _q.startswith(PORT_PREFIXES):
|
||||
continue
|
||||
if _q in NOTIF_SKIP:
|
||||
skipped.append((_q, '按约定保持原厂')); continue
|
||||
if _q.rsplit('/', 1)[-1].endswith('launcher.bin'):
|
||||
continue
|
||||
_got = s5_all[_q]
|
||||
if _got[0] is None:
|
||||
continue
|
||||
qrom, oino = _got
|
||||
tino = s4_all.get(_q)
|
||||
if tino is None:
|
||||
skipped.append((_q, '本机没有这个槽位')); continue
|
||||
tgt_blob = s4.read(tino)
|
||||
src_blob = qrom.read(oino)
|
||||
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
|
||||
how = None
|
||||
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
|
||||
new, how = ip.encode_into_slot(simg, tgt_blob, tino.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_to_bbox(simg, ink_bbox(timg), (th['w'], th['h']), fit=True)
|
||||
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) != tino.size:
|
||||
failed.append((_q, '编码 %d != 槽位 %d' % (len(new), tino.size))); continue
|
||||
if new == tgt_blob:
|
||||
skipped.append((_q, '两边已经一致')); continue
|
||||
name = 'sys_' + _q.replace('/', '_')[:-4]
|
||||
new_blobs[name] = new
|
||||
extra_ino[name] = tino
|
||||
mijia_paths.add(_q)
|
||||
plan.append(dict(name=name, kind='notif', path=_q, offset=tino.data_off, length=tino.size,
|
||||
s4_format=ip.FMT.get(th['cf'], '0x%02x' % th['cf']), s4_flags=th['flags'],
|
||||
s5_bytes=len(src_blob), s5_from='q63 %s' % _q, method=how))
|
||||
notif_n += 1
|
||||
print(' %-46s <- q63 %-30s %s' % (_q, _q.split('/')[-1], how))
|
||||
print(' 通知/电话/录音机写入 %d 条' % notif_n)
|
||||
|
||||
n_inset = sum(1 for p in plan if p['method'] == 'inset')
|
||||
print(f'{"name":<24}{"slot":>8}{"fmt":>10}{"S5 bytes":>10} method')
|
||||
for p in plan:
|
||||
print(f'{p["name"]:<24}{p["length"]:>8}{p["s4_format"]:>10}{p["s5_bytes"]:>10} {p["method"]}')
|
||||
for k, why in skipped:
|
||||
print(f'{k:<24}{"-":>8}{"-":>10}{"-":>10} SKIP {why}')
|
||||
for k, why in failed:
|
||||
print(f'{k:<24}{"-":>8}{"-":>10}{"-":>10} FAIL {why}')
|
||||
print(f'\nreplace {len(plan)} (inset {n_inset}) skip {len(skipped)} failed {len(failed)}')
|
||||
|
||||
# 同一槽位可能被多个组写过(sys / 顶栏 / 米家)-> 按 **inode** 去重,后写的赢
|
||||
_byoff = {}
|
||||
for _n, _i in extra_ino.items():
|
||||
_byoff[_i.off] = (_n, _i)
|
||||
extra_ino = {_n: _i for _n, _i in _byoff.values()}
|
||||
|
||||
all_items = [(key, ino) for key, ino in sorted(s4t.items()) if key in new_blobs]
|
||||
all_items += [(name, ino) for name, ino in extra_ino.items()]
|
||||
|
||||
# plan 里同一槽位的旧记录也去掉(只留最后一条)
|
||||
_dedup = {}
|
||||
for _p in plan:
|
||||
_dedup[_p['name']] = _p
|
||||
plan = list(_dedup.values())
|
||||
patch, rollback = bytearray(), bytearray()
|
||||
for key, ino in all_items:
|
||||
new, old = new_blobs[key], s4.read(ino)
|
||||
patch += struct.pack('<II', ino.data_off, len(new)) + new
|
||||
rollback += struct.pack('<II', ino.data_off, len(old)) + old
|
||||
open(os.path.join(OUTDIR, 'icons.patch'), 'wb').write(patch)
|
||||
open(os.path.join(OUTDIR, 'icons.rollback'), 'wb').write(rollback)
|
||||
json.dump(dict(target='Xiaomi Watch S4 eSIM (o62lte) vela_app.bin',
|
||||
source='Xiaomi Watch S5 (q63) v4.101.020 icons, inset to S4 convention',
|
||||
sizing=f'full-bleed sources scaled to {144-2*MARGIN}x{144-2*MARGIN} '
|
||||
f'and centred with a {MARGIN}px transparent margin',
|
||||
image_sha256=hashlib.sha256(s4.data).hexdigest(),
|
||||
replace_count=len(plan), records=plan,
|
||||
skipped=[dict(name=k, reason=w) for k, w in skipped]),
|
||||
open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
|
||||
|
||||
print('\nwriting patched image ...')
|
||||
shutil.copyfile(S4, PATCHED)
|
||||
with open(PATCHED, 'r+b') as f:
|
||||
for key, ino in all_items:
|
||||
f.seek(ino.data_off)
|
||||
f.write(new_blobs[key])
|
||||
|
||||
r2 = Romfs(PATCHED)
|
||||
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in s4.entries()]
|
||||
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in r2.entries()]
|
||||
print(f'entries {len(a)} -> {len(b)} inode table identical: {a == b}')
|
||||
print(f'superblock identical: {s4.sb_size == r2.sb_size and s4.sb_cksum == r2.sb_cksum}')
|
||||
i1 = {p: i for p, i in s4.entries()}
|
||||
i2 = {p: i for p, i in r2.entries()}
|
||||
changed = sorted(p for p in i1 if s4.read(i1[p]) != r2.read(i2[p]))
|
||||
diff_items = [(k, i) for k, i in all_items if new_blobs[k] != s4.read(i)]
|
||||
print(f'changed files ({len(changed)}), 预期 {len(diff_items)}')
|
||||
assert len(changed) == len(diff_items), 'changed %d != expected %d' % (len(changed), len(diff_items))
|
||||
assert all(c.rsplit('/', 1)[-1].endswith('launcher.bin') or c in cc_paths or c in walk_paths
|
||||
or c in mijia_paths for c in changed), changed[:5]
|
||||
print('sha256', hashlib.sha256(open(PATCHED, "rb").read()).hexdigest())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""把 assets/ 里的 UI 素材转成 LVGL v9 二进制图片(.bin),供表盘运行时加载。
|
||||
|
||||
格式(已在真机验证):
|
||||
header 12B: magic=0x19 | cf | flags | w | h | stride | reserved
|
||||
cf=0x10 ARGB8888 -> 数据为 BGRA,长度 = w*h*4
|
||||
编码器用 iconpack.encode(与图标包同一套,字节级验证过)。
|
||||
"""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import iconpack as ip
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from paths import PROJ, DOCS
|
||||
ASSETS = os.path.join(PROJ, 'assets')
|
||||
OUT = os.path.join(PROJ, 'ui_images')
|
||||
|
||||
# 显示尺寸取自 CSS:列表图标 30x30,返回箭头 17x28
|
||||
SPEC = [
|
||||
('back', 'backarrow.png', 17, 28),
|
||||
('ic_help', 'MdiLightbulbOutline.png', 30, 30),
|
||||
('ic_replace', 'MdiBandage.png', 30, 30),
|
||||
('ic_about', 'MdiInformationOutline.png', 30, 30),
|
||||
('ic_check', 'MdiClipboardTextSearchOutline.png', 30, 30),
|
||||
('ic_rollback', 'MdiReplyAllOutline.png', 30, 30),
|
||||
('ic_qq', 'qq.png', 30, 30),
|
||||
]
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for name, png, w, h in SPEC:
|
||||
p = os.path.join(ASSETS, png)
|
||||
if not os.path.exists(p):
|
||||
print(f' 缺少 {png}'); continue
|
||||
img = Image.open(p).convert('RGBA')
|
||||
if img.size != (w, h):
|
||||
img = img.resize((w, h), Image.LANCZOS)
|
||||
blob = ip.encode(img, ip.CF_ARGB8888, w, h, 0)
|
||||
dst = os.path.join(OUT, name + '.bin')
|
||||
open(dst, 'wb').write(blob)
|
||||
print(f' {name:<12} <- {png:<34} {w}x{h} {len(blob)} bytes')
|
||||
|
||||
# 关于页的 logo:封面图缩到 78x78,并加圆形遮罩
|
||||
# (封面是圆形图案 + 黑色方角,不裁成圆的话会在墨绿卡片上露出黑方块)
|
||||
logo_src = os.path.join(ASSETS, 'cover.png')
|
||||
if os.path.exists(logo_src):
|
||||
L = 78
|
||||
img = Image.open(logo_src).convert('RGBA').resize((L, L), Image.LANCZOS)
|
||||
mask = Image.new('L', (L, L), 0)
|
||||
ImageDraw.Draw(mask).ellipse([0, 0, L - 1, L - 1], fill=255)
|
||||
img.putalpha(mask)
|
||||
blob = ip.encode(img, ip.CF_ARGB8888, L, L, 0)
|
||||
open(os.path.join(OUT, 'logo.bin'), 'wb').write(blob)
|
||||
# 顺便存一份 PNG 给本机预览用(注意别放进打包目录,会一起被打进去)
|
||||
prev = os.path.join(DOCS, 'logo_preview.png')
|
||||
os.makedirs(os.path.dirname(prev), exist_ok=True)
|
||||
img.save(prev)
|
||||
print(f' {"logo":<12} <- cover.png(圆形裁切) {L}x{L} {len(blob)} bytes')
|
||||
|
||||
print('written to', OUT)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""页面规格 + 本机渲染器:按 CSS 逐条落地,用真 MiSans 字体和真素材渲染预览。
|
||||
|
||||
CSS -> 实际能力 的映射(已在真机验证过):
|
||||
left/top/width/height -> x / y / w / h(466 画布绝对坐标)
|
||||
background -> bg_color
|
||||
border-radius -> radius(50x50 圆的 radius=25)
|
||||
color (alpha 1) -> text_color '#rrggbb'
|
||||
color (alpha 0.6) -> 白 60% 叠在纯黑底上 = #999999(页面底色是黑,见下)
|
||||
font-weight 600 -> MiSans-Semibold
|
||||
font-weight 500 -> MiSans-Medium
|
||||
vertical-align middle -> vcenter=True
|
||||
多色文本 -> LVGL recolor: "灰#ffffff 白#灰"
|
||||
"""
|
||||
import os
|
||||
|
||||
from paths import PROJ, DOCS
|
||||
ROOT = PROJ
|
||||
ASSETS = os.path.join(ROOT, 'assets')
|
||||
W = H = 466
|
||||
BG = (0, 0, 0) # 封面四角纯黑 -> 页面底色
|
||||
GRAY = '999999' # rgba(255,255,255,0.6) 叠黑
|
||||
WHITE = 'ffffff'
|
||||
PLATE = '002927' # rgba(0,41,39,1)
|
||||
CIRCLE = '4f4f4f' # rgba(79,79,79,1)
|
||||
OK = '43cf7c' # rgba(67,207,124,1)
|
||||
ERR = 'd43030' # rgba(212,48,48,1)
|
||||
|
||||
S600, S500 = 'semibold', 'medium'
|
||||
|
||||
# 列表项几何(来自第一个表项的 CSS,其余按同一相对关系排布)
|
||||
ITEM_X, ITEM_W, ITEM_H, ITEM_R = 49, 368, 88, 34
|
||||
PITCH = 96 # 88 + 8px 间距
|
||||
ICONC_DX, ICONC_DY, ICONC_SZ = 20, 19, 50 # 圆形遮罩 69-49 / 104-85
|
||||
ICON_DX, ICON_DY, ICON_SZ = 30, 29, 30 # 图标图片 79-49 / 114-85
|
||||
TITLE_DX, TITLE_DY, TITLE_W, TITLE_H = 80, 17, 208, 54 # 129-49 / 102-85
|
||||
|
||||
|
||||
def item(y, icon, title):
|
||||
"""一个列表项:底板 + 圆形遮罩 + 图标 + 标题"""
|
||||
return [
|
||||
dict(kind='rect', x=ITEM_X, y=y, w=ITEM_W, h=ITEM_H, bg=PLATE, radius=ITEM_R),
|
||||
dict(kind='circle', x=ITEM_X + ICONC_DX, y=y + ICONC_DY,
|
||||
w=ICONC_SZ, h=ICONC_SZ, bg=CIRCLE),
|
||||
dict(kind='image', x=ITEM_X + ICON_DX, y=y + ICON_DY,
|
||||
w=ICON_SZ, h=ICON_SZ, src=os.path.join(ASSETS, icon)),
|
||||
dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY,
|
||||
w=TITLE_W, h=TITLE_H, text=title, size=28, weight=S500,
|
||||
color=WHITE, align='left', vcenter=True, lh=37.13),
|
||||
]
|
||||
|
||||
|
||||
def clock(t='10:24'):
|
||||
return dict(kind='label', x=168, y=0, w=130, h=45, text=t, size=28,
|
||||
weight=S600, color=GRAY, align='center', vcenter=True, lh=33.6)
|
||||
|
||||
|
||||
def title(text, x=106, w=255):
|
||||
return dict(kind='label', x=x, y=34, w=w, h=45, text=text, size=30,
|
||||
weight=S500, color=WHITE, align='center', vcenter=True, lh=44.8)
|
||||
|
||||
|
||||
def back_arrow():
|
||||
return dict(kind='image', x=160, y=43, w=17, h=28,
|
||||
src=os.path.join(ASSETS, 'backarrow.png'))
|
||||
|
||||
|
||||
def big_button(y=365, text='确认替换'):
|
||||
return [
|
||||
dict(kind='rect', x=108, y=y, w=250, h=80, bg=PLATE, radius=40),
|
||||
dict(kind='label', x=173, y=y + 13, w=120, h=54, text=text, size=28,
|
||||
weight=S500, color=WHITE, align='center', vcenter=True, lh=37.13),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 1. 首页
|
||||
PAGE_HOME = [
|
||||
clock(),
|
||||
title('OS4 Icons'),
|
||||
*item(85, 'MdiLightbulbOutline.png', '快速帮助'),
|
||||
*item(181, 'MdiBandage.png', '开始替换'),
|
||||
*item(277, 'MdiInformationOutline.png', '关于'),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- 2. 快速帮助
|
||||
_HELP_HEAD = [clock(), back_arrow(), title('快速帮助', x=179, w=128)]
|
||||
_HELP_I1 = item(85, 'MdiClipboardTextSearchOutline.png', '环境预检查')
|
||||
_HELP_D1 = dict(kind='label', x=65, y=176, w=338, h=72,
|
||||
text='检查原始资源状态、分区可写情况等。', size=24, weight=S500,
|
||||
color=GRAY, align='left', vtop=True, lh=31.82)
|
||||
_HELP_I2 = item(256, 'MdiReplyAllOutline.png', '回滚修改')
|
||||
_HELP_D2 = dict(kind='label', x=65, y=344, w=334, h=72,
|
||||
text='将图标恢复到系统原始样式。', size=24, weight=S500,
|
||||
color=GRAY, align='left', vtop=True, lh=31.82)
|
||||
|
||||
PAGE_HELP = _HELP_HEAD + _HELP_I1 + [_HELP_D1] + _HELP_I2 + [_HELP_D2]
|
||||
|
||||
# 帮助页的两种结果状态(同一标签换色换字)
|
||||
HELP_DESC_OK = dict(kind='label', x=65, y=176, w=338, h=72,
|
||||
text='预检查通过 | 38 条 | 2.58MB\n您可以返回主页开始修改。',
|
||||
size=24, weight=S500, color=OK, align='left', vtop=True, lh=31.82)
|
||||
HELP_DESC_ERR = dict(kind='label', x=65, y=176, w=338, h=72,
|
||||
text='预检查失败:分区头不是 ROMFS,位置 0x00。',
|
||||
size=24, weight=S500, color=ERR, align='left', vtop=True, lh=31.82)
|
||||
|
||||
PAGE_HELP_OK = _HELP_HEAD + _HELP_I1 + [HELP_DESC_OK] + _HELP_I2 + [_HELP_D2]
|
||||
PAGE_HELP_ERR = _HELP_HEAD + _HELP_I1 + [HELP_DESC_ERR] + _HELP_I2 + [_HELP_D2]
|
||||
|
||||
# ---------------------------------------------------------------- 3. 开始替换
|
||||
# 多色文本**不能**用 recolor(AP 里那几处是图片重着色,不是文本属性),
|
||||
# 所以拆成多个标签 + 手工算 x。折行也写死,否则多色片段位置不确定。
|
||||
# 宽度用真 MiSans 24px 量得:
|
||||
# "的系统版本号为 " = 177 "3.202.79" = 99
|
||||
# "Xiaomi Watch S4 eSIM(非" = 313 "15 周年纪念版)" = 178
|
||||
WARN_LABELS = [
|
||||
dict(kind='label', x=64, y=90, w=338, h=70,
|
||||
text='替换图标包之前,请先确认自己\n的系统版本号为 ',
|
||||
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
|
||||
dict(kind='label', x=241, y=123, w=104, h=33, text='3.202.79',
|
||||
size=24, weight=S500, color=WHITE, align='left', vtop=True, lh=33),
|
||||
dict(kind='label', x=340, y=123, w=30, h=33, text='!',
|
||||
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
|
||||
dict(kind='label', x=64, y=189, w=338, h=33, text='请确认自己的手表型号为',
|
||||
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
|
||||
dict(kind='label', x=64, y=222, w=338, h=70,
|
||||
text='Xiaomi Watch S4 eSIM(非\n15 周年纪念版)',
|
||||
size=24, weight=S500, color=WHITE, align='left', vtop=True, lh=33),
|
||||
dict(kind='label', x=242, y=255, w=30, h=33, text='!',
|
||||
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
|
||||
dict(kind='label', x=64, y=321, w=338, h=33, text='搞机有风险,变砖后果自负。',
|
||||
size=24, weight=S500, color=GRAY, align='left', vtop=True, lh=33),
|
||||
]
|
||||
|
||||
PAGE_CONFIRM = [
|
||||
clock(),
|
||||
back_arrow(),
|
||||
title('开始替换', x=179, w=128),
|
||||
*WARN_LABELS,
|
||||
*big_button(365, '确认替换'),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- 4. 替换中
|
||||
LOG_MID = ('> 开始替换\n'
|
||||
'> 读取 resource.bin\n'
|
||||
'> 写入 12/38\n'
|
||||
'> 写入 38/38 完成,0 失败\n'
|
||||
'> 替换完毕,请重启手表。若发现问题,请回滚修改。')
|
||||
|
||||
PAGE_RUNNING = [
|
||||
clock(),
|
||||
title('正在替换', x=179, w=128),
|
||||
dict(kind='label', x=64, y=90, w=338, h=269, text=LOG_MID, size=24,
|
||||
weight=S500, color=WHITE, align='left', vtop=True, lh=31.82),
|
||||
*big_button(365, '重启手表'),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- 5. 关于
|
||||
PAGE_ABOUT = [
|
||||
clock(),
|
||||
dict(kind='image', x=191, y=43, w=17, h=28,
|
||||
src=os.path.join(ASSETS, 'backarrow.png')),
|
||||
title('关于', x=212, w=64),
|
||||
dict(kind='rect', x=52, y=79, w=362, h=204, bg=PLATE, radius=30),
|
||||
dict(kind='image', x=194, y=108, w=78, h=78,
|
||||
src=os.path.join(DOCS, 'logo_preview.png')),
|
||||
dict(kind='label', x=164, y=192, w=138, h=38, text='OS4 Icons',
|
||||
size=28, weight=S500, color=WHITE, align='left', vtop=True, lh=38),
|
||||
dict(kind='label', x=210, y=227, w=45, h=27, text='1.0.0',
|
||||
size=20, weight=S500, color=GRAY, align='left', vtop=True, lh=27),
|
||||
*item(291, 'qq.png', '732681995'),
|
||||
]
|
||||
|
||||
PAGES = {
|
||||
'home': PAGE_HOME,
|
||||
'help': PAGE_HELP,
|
||||
'help_ok': PAGE_HELP_OK,
|
||||
'help_err': PAGE_HELP_ERR,
|
||||
'confirm': PAGE_CONFIRM,
|
||||
'about': PAGE_ABOUT,
|
||||
'running': PAGE_RUNNING,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""项目里所有脚本的路径入口 —— 换机器 / 换目录只看这一个文件。
|
||||
|
||||
默认布局(都相对项目根):
|
||||
inputs/target/ 目标固件的分区镜像(要自己放,见 README)
|
||||
inputs/source/ 图标来源的分区镜像(要自己放)
|
||||
inputs/fonts/ 可选:MiSans-Medium.ttf / MiSans-Semibold.ttf(渲染预览用)
|
||||
vendor/ 第三方 LuaDevTemplate-main(里面有 Compiler.exe,要自己放)
|
||||
.work/ 构建工作区(产物,可删)
|
||||
dist/ 交付的 .face
|
||||
docs/ 说明与预览图
|
||||
|
||||
环境变量可覆盖:ICON_EDIT_INPUTS / ICON_EDIT_VENDOR / ICON_EDIT_WORK /
|
||||
ICON_EDIT_DIST / ICON_EDIT_DOCS / ICON_EDIT_FONTS
|
||||
"""
|
||||
import os
|
||||
|
||||
PROJ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _p(env, default):
|
||||
return os.environ.get(env) or os.path.join(PROJ, default)
|
||||
|
||||
|
||||
INPUTS = _p('ICON_EDIT_INPUTS', 'inputs')
|
||||
VENDOR = _p('ICON_EDIT_VENDOR', 'vendor')
|
||||
WORK = _p('ICON_EDIT_WORK', '.work')
|
||||
DIST = _p('ICON_EDIT_DIST', 'dist')
|
||||
DOCS = _p('ICON_EDIT_DOCS', 'docs')
|
||||
FONTS = os.environ.get('ICON_EDIT_FONTS') or os.path.join(INPUTS, 'fonts')
|
||||
TEMPLATE = os.path.join(VENDOR, 'LuaDevTemplate-main')
|
||||
INPUT_TARGET = os.path.join(INPUTS, 'target')
|
||||
INPUT_SOURCE = os.path.join(INPUTS, 'source')
|
||||
|
||||
for _d in (WORK, DIST, DOCS, INPUT_TARGET, INPUT_SOURCE):
|
||||
os.makedirs(_d, exist_ok=True)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""按页面规格渲染 466x466 预览图(真 MiSans 字体 + 真素材 + 圆形遮罩)。
|
||||
|
||||
支持 kind: rect / circle / image / label
|
||||
label 支持 recolor 标记 "#RRGGBB 文字#"(LVGL 的写法),以及 align/vcenter/vtop/lh
|
||||
"""
|
||||
import os, re, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from paths import PROJ, INPUTS, FONTS, WORK
|
||||
ROOT = PROJ
|
||||
FONT_FILES = {'semibold': 'MiSans-Semibold.ttf', 'medium': 'MiSans-Medium.ttf',
|
||||
'regular': 'MiSans-Regular.ttf'}
|
||||
W = H = 466
|
||||
_fc = {}
|
||||
|
||||
|
||||
def _font_from_firmware(name):
|
||||
"""从 inputs/target/vela_font.bin 里现取 MiSans(固件里就有这几个 ttf)。"""
|
||||
p = os.path.join(INPUTS, 'target', 'vela_font.bin')
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
try:
|
||||
from romfs import Romfs
|
||||
r = Romfs(p)
|
||||
except Exception:
|
||||
return None
|
||||
for path, ino in r.entries():
|
||||
if path == name:
|
||||
d = os.path.join(WORK, 'fonts')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
out = os.path.join(d, name)
|
||||
open(out, 'wb').write(r.read(ino))
|
||||
return out
|
||||
return None
|
||||
|
||||
|
||||
def font(weight, size):
|
||||
k = (weight, size)
|
||||
if k in _fc:
|
||||
return _fc[k]
|
||||
name = FONT_FILES.get(weight, FONT_FILES['medium'])
|
||||
p = os.path.join(FONTS, name)
|
||||
if not os.path.exists(p):
|
||||
p = _font_from_firmware(name) or p
|
||||
if not os.path.exists(p):
|
||||
for alt in (r'C:\Windows\Fonts\msyh.ttc', r'C:\Windows\Fonts\msyhbd.ttc',
|
||||
r'C:\Windows\Fonts\arial.ttf'):
|
||||
if os.path.exists(alt):
|
||||
p = alt
|
||||
print(' [warn] %s 缺失,预览改用 %s(只影响预览缩略图)' % (name, os.path.basename(alt)))
|
||||
break
|
||||
_fc[k] = ImageFont.truetype(p, size) if os.path.exists(p) else ImageFont.load_default()
|
||||
return _fc[k]
|
||||
|
||||
|
||||
def _rgb(c):
|
||||
return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def split_recolor(text, base):
|
||||
"""LVGL 写法: '灰#ffffff 白#灰'
|
||||
- # + 6 位十六进制 -> 切到该颜色(该字符不显示)
|
||||
- 再次遇到单独的 # -> 结束着色,回到基础色(该字符不显示)
|
||||
"""
|
||||
runs, buf, cur, incol = [], '', base, False
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if text[i] == '#':
|
||||
if re.match(r'[0-9a-fA-F]{6}', text[i + 1:i + 7]):
|
||||
runs.append((cur, buf)); buf = ''
|
||||
cur = text[i + 1:i + 7].lower(); incol = True
|
||||
i += 7
|
||||
continue
|
||||
if incol:
|
||||
runs.append((cur, buf)); buf = ''
|
||||
cur = base; incol = False
|
||||
i += 1
|
||||
continue
|
||||
buf += text[i]; i += 1
|
||||
runs.append((cur, buf))
|
||||
return runs
|
||||
|
||||
|
||||
def wrap_runs(d, runs, f, maxw):
|
||||
"""按像素宽度折行,返回 [[(color, text), ...], ...](尽量保持原换行)"""
|
||||
lines = [[]]
|
||||
for color, chunk in runs:
|
||||
for k, seg in enumerate(chunk.split('\n')):
|
||||
if k:
|
||||
lines.append([])
|
||||
if not seg:
|
||||
continue
|
||||
cur = ''
|
||||
for ch in seg:
|
||||
if d.textlength(cur + ch, font=f) > maxw and cur:
|
||||
lines[-1].append((color, cur))
|
||||
lines.append([])
|
||||
cur = ch
|
||||
else:
|
||||
cur += ch
|
||||
if cur:
|
||||
lines[-1].append((color, cur))
|
||||
return lines
|
||||
|
||||
|
||||
OVERFLOW = [] # (标签文本前 12 字, css_lh, 行数, 块高, 框高)
|
||||
|
||||
|
||||
def draw_label(img, d, it):
|
||||
f = font(it.get('weight', 'medium'), it['size'])
|
||||
runs = split_recolor(it['text'], it.get('color', 'ffffff')) \
|
||||
if it.get('recolor') else [(it.get('color', 'ffffff'), it['text'])]
|
||||
asc, desc = f.getmetrics()
|
||||
natural = asc + desc
|
||||
lh = max(it.get('lh', 0), natural) # LVGL 用字体自身行高,取较大者预测
|
||||
lines = wrap_runs(d, runs, f, it['w'])
|
||||
total = lh * len(lines)
|
||||
if total > it['h'] + 0.5:
|
||||
OVERFLOW.append((it['text'][:14].replace('\n', '/'),
|
||||
round(it.get('lh', 0), 2), len(lines),
|
||||
round(total, 1), it['h'], round(natural, 2)))
|
||||
if it.get('vcenter'):
|
||||
y = it['y'] + (it['h'] - total) / 2
|
||||
elif it.get('vtop'):
|
||||
y = it['y']
|
||||
else:
|
||||
y = it['y'] + (it['h'] - total) / 2
|
||||
for ln in lines:
|
||||
wsum = sum(d.textlength(t, font=f) for _, t in ln)
|
||||
if it.get('align') == 'center':
|
||||
x = it['x'] + (it['w'] - wsum) / 2
|
||||
elif it.get('align') == 'right':
|
||||
x = it['x'] + it['w'] - wsum
|
||||
else:
|
||||
x = it['x']
|
||||
for color, t in ln:
|
||||
d.text((x, y), t, font=f, fill=_rgb(color) + (255,))
|
||||
x += d.textlength(t, font=f)
|
||||
y += lh
|
||||
|
||||
|
||||
def render(items, out, mask=True, ring=False):
|
||||
img = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
for it in items:
|
||||
k = it.get('kind')
|
||||
if k == 'rect':
|
||||
box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1]
|
||||
r = it.get('radius', 0)
|
||||
(d.rounded_rectangle if r else d.rectangle)(
|
||||
box, radius=r, fill=_rgb(it['bg']) + (255,)) if r else d.rectangle(
|
||||
box, fill=_rgb(it['bg']) + (255,))
|
||||
elif k == 'circle':
|
||||
box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1]
|
||||
d.ellipse(box, fill=_rgb(it['bg']) + (255,))
|
||||
elif k == 'image':
|
||||
p = it['src']
|
||||
if os.path.exists(p):
|
||||
im = Image.open(p).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS)
|
||||
img.alpha_composite(im, (it['x'], it['y']))
|
||||
elif k == 'label':
|
||||
draw_label(img, d, it)
|
||||
if mask:
|
||||
m = Image.new('L', (W, H), 0)
|
||||
ImageDraw.Draw(m).ellipse([0, 0, W - 1, H - 1], fill=255)
|
||||
img.putalpha(m)
|
||||
base = Image.new('RGB', (W, H), (0, 0, 0))
|
||||
base.paste(img, (0, 0), img)
|
||||
if ring:
|
||||
ImageDraw.Draw(base).ellipse([0, 0, W - 1, H - 1], outline=(80, 80, 90))
|
||||
base.save(out)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from pages import PAGES
|
||||
outs = []
|
||||
for name, items in PAGES.items():
|
||||
p = os.path.join(ROOT, '_out', f'ui_{name}.png')
|
||||
render(items, p)
|
||||
outs.append(p)
|
||||
print('wrote', p)
|
||||
# 拼一张总览图
|
||||
cols = 3
|
||||
rows = (len(outs) + cols - 1) // cols
|
||||
sheet = Image.new('RGB', (cols * (W + 12) + 12, rows * (H + 12) + 12), (28, 28, 32))
|
||||
for i, p in enumerate(outs):
|
||||
im = Image.open(p)
|
||||
sheet.paste(im, (12 + (i % cols) * (W + 12), 12 + (i // cols) * (H + 12)))
|
||||
sp = os.path.join(ROOT, '_out', 'ui_all.png')
|
||||
sheet.save(sp)
|
||||
print('wrote', sp, sheet.size)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Minimal reader for Xiaomi Vela ROMFS partition images (Linux 'rom1fs' variant).
|
||||
|
||||
Layout (big-endian):
|
||||
super : magic "-rom1fs-", u32 size, u32 checksum, name (NUL-term, padded to 16)
|
||||
inode : u32 next (low 4 bits = ROMFH_ type flags), u32 spec, u32 size, u32 checksum,
|
||||
name (NUL-term, padded to 16), then file data immediately after.
|
||||
type : 0=hardlink (spec = target inode offset), 1=dir (spec = offset of first child),
|
||||
2=regular file, 8=exec bit OR-ed in.
|
||||
For a directory, its children are the chain starting at `spec`, linked by
|
||||
`next & ~0xF`, and they stay inside the directory's own block.
|
||||
"""
|
||||
import struct, os
|
||||
|
||||
ROUND16 = lambda n: (n + 15) & ~15
|
||||
|
||||
class Inode:
|
||||
__slots__ = ('off', 'next', 'spec', 'size', 'cksum', 'name', 'namelen', 'data_off', 'kind')
|
||||
|
||||
@property
|
||||
def type_flags(self):
|
||||
return self.next & 0xF
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return self.next & 0x7
|
||||
|
||||
@property
|
||||
def exec_bit(self):
|
||||
return bool(self.next & 0x8)
|
||||
|
||||
@property
|
||||
def next_off(self):
|
||||
return self.next & ~0xF
|
||||
|
||||
KIND = {0: 'HRD', 1: 'DIR', 2: 'REG', 3: 'SYM', 4: 'BLK', 5: 'CHR', 6: 'SCK', 7: 'FIF'}
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<Inode @0x{self.off:x} {self.KIND[self.type]}{"X" if self.exec_bit else " "} '
|
||||
f'{self.name!r} size={self.size}>')
|
||||
|
||||
|
||||
class Romfs:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.data = open(path, 'rb').read()
|
||||
d = self.data
|
||||
if d[0:8] != b'-rom1fs-':
|
||||
raise ValueError('not a romfs image')
|
||||
self.sb_size, self.sb_cksum = struct.unpack('>II', d[8:16])
|
||||
end = d.index(b'\x00', 16)
|
||||
self.volume = d[16:end].decode('latin1')
|
||||
self.root_off = 16 + ROUND16(end - 16 + 1)
|
||||
|
||||
def inode(self, off):
|
||||
i = Inode()
|
||||
i.off = off
|
||||
i.next, i.spec, i.size, i.cksum = struct.unpack('>IIII', self.data[off:off+16])
|
||||
p = off + 16
|
||||
end = self.data.find(b'\x00', p, p + 256)
|
||||
if end < 0:
|
||||
raise ValueError(f'bad name at 0x{off:x}')
|
||||
i.name = self.data[p:end].decode('latin1')
|
||||
i.namelen = end - p
|
||||
i.data_off = off + 16 + ROUND16(i.namelen + 1)
|
||||
return i
|
||||
|
||||
def walk(self, first, limit, prefix=''):
|
||||
"""Yield (path, Inode) for every entry reachable from `first` below `limit`.
|
||||
|
||||
`limit` bounds the sibling chain. The LAST entry of a chain has next==0,
|
||||
but its own children still live after it: they inherit the parent's limit
|
||||
instead of being bounded by that zero.
|
||||
"""
|
||||
off = first
|
||||
seen = set()
|
||||
while off and (limit == 0 or off < limit) and off not in seen:
|
||||
seen.add(off)
|
||||
i = self.inode(off)
|
||||
path = prefix + i.name
|
||||
yield path, i
|
||||
if i.type == 1 and i.spec: # directory -> first child at spec
|
||||
yield from self.walk(i.spec, i.next_off or limit, path + '/')
|
||||
off = i.next_off
|
||||
|
||||
def entries(self):
|
||||
"""All entries of the image, as (path, Inode)."""
|
||||
return self.walk(self.root_off + 0x20, len(self.data))
|
||||
|
||||
def read(self, ino):
|
||||
return self.data[ino.data_off:ino.data_off + ino.size]
|
||||
|
||||
|
||||
def img_header(blob):
|
||||
if len(blob) < 12 or blob[0] != 0x19:
|
||||
return None
|
||||
magic, cf, flags, w, h, stride, res = struct.unpack('<BBHHHHH', blob[:12])
|
||||
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=res)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
r = Romfs(sys.argv[1])
|
||||
print(f'{os.path.basename(sys.argv[1])}: volume={r.volume!r} size={r.sb_size} '
|
||||
f'file={len(r.data)} root=0x{r.root_off:x}')
|
||||
n = 0
|
||||
for path, i in r.entries():
|
||||
n += 1
|
||||
if len(sys.argv) > 2 and sys.argv[2] not in path:
|
||||
continue
|
||||
kind = ('DIR ' if i.type == 1 else 'LNK ' if i.type == 0 else 'FILE')
|
||||
print(f'{kind} {i.size:>9} {path}')
|
||||
print(f'-- total entries: {n}')
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""S4 图标表盘交付物的独立校验(不信任构建脚本的账本)。
|
||||
|
||||
从 dist/S5Icons.face 里把 Lua 源码抠出来 -> 解析 UIIMG / PAYTBL 两张表 ->
|
||||
每一条都拿去和 **固件镜像**(inputs/target 原厂 + .work/s4_from_s5 改后)以及
|
||||
ui_images/ 目录逐字节比对;另外单独验证 voice_aivs 那条的字节就是
|
||||
assets/override/voice_aivs.png 按槽位格式重编码的结果。
|
||||
|
||||
用法: python tools/s4e_verify.py
|
||||
"""
|
||||
import os, re, sys, json, hashlib
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from romfs import Romfs
|
||||
from lvgl_bin import decode_lvgl_bin
|
||||
import iconpack as ip
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from paths import PROJ, INPUTS, WORK, DIST
|
||||
|
||||
FACE = os.path.join(DIST, 'S5Icons.face')
|
||||
STOCK = os.path.join(INPUTS, 'target', 'vela_app.bin')
|
||||
PATCHED = os.path.join(WORK, 's4_from_s5', 'vela_app_s5icons.bin')
|
||||
MANIFEST = os.path.join(WORK, 's4_from_s5', 'manifest.json')
|
||||
WID = '362150101'
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def ck(cond, msg):
|
||||
print((' OK ' if cond else ' FAIL ') + msg)
|
||||
if not cond:
|
||||
fails.append(msg)
|
||||
|
||||
|
||||
face = open(FACE, 'rb').read()
|
||||
_end = face.find(b'return ui\n')
|
||||
if _end < 0:
|
||||
raise SystemExit('face 里找不到 Lua 结尾')
|
||||
_start = face.rfind(b'--[[', 0, _end)
|
||||
lua = face[_start:_end + len(b'return ui\n')]
|
||||
if not lua.startswith(b'--[[--') or b'local UIIMG = {' not in lua:
|
||||
raise SystemExit('Lua 提取失败:%r ... %r' % (lua[:24], lua[-24:]))
|
||||
txt = lua.decode('utf-8')
|
||||
|
||||
print('== 头部 ==')
|
||||
ck(face[0:4] == b'\x5a\xa5\x34\x12', 'magic 5a a5 34 12')
|
||||
ck(face[40:50] == (WID + '\x00').encode(), 'id = %r' % face[40:50])
|
||||
ck(face[5] == 0, 'b5 = %d(固件自带表盘也是 0)' % face[5])
|
||||
ck(face.count(lua) == 1, 'Lua 源码原样出现 %d 次(%d B)' % (face.count(lua), len(lua)))
|
||||
print(' 文件 %d B' % len(face))
|
||||
|
||||
print('== UI 图片 ==')
|
||||
ui = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt)
|
||||
files = {f[:-4]: open(os.path.join(PROJ, 'ui_images', f), 'rb').read()
|
||||
for f in os.listdir(os.path.join(PROJ, 'ui_images')) if f.endswith('.bin')}
|
||||
ck(len(ui) == len(files), 'UIIMG %d 条(ui_images 里 %d 个)' % (len(ui), len(files)))
|
||||
for o, l, n in ui:
|
||||
o, l = int(o), int(l)
|
||||
ck(n in files and face[o:o + l] == files[n],
|
||||
'%-12s @0x%06x len=%-6d 与 ui_images/%s.bin 一致' % (n, o, l, n))
|
||||
|
||||
print('== 负载表结构 ==')
|
||||
_pay_at = txt.index('pay = {')
|
||||
_rb_at = txt.index('rb = {', _pay_at)
|
||||
pay = [(int(a), int(b), int(c)) for a, b, c in re.findall(r'\{(\d+),(\d+),(\d+)\}', txt[_pay_at:_rb_at])]
|
||||
rb = [(int(a), int(b), int(c)) for a, b, c in re.findall(r'\{(\d+),(\d+),(\d+)\}', txt[_rb_at:])]
|
||||
exp = json.load(open(MANIFEST, encoding='utf-8'))['records'] # 计划替换的 81 条
|
||||
pj = json.load(open(os.path.join(PROJ, 'payload.json'), encoding='utf-8'))
|
||||
emit = pj['records'] # 实际下发的(含 canary)
|
||||
dropped = pj['dropped_identical']
|
||||
ck(len(emit) - 1 == len(exp) - len(dropped),
|
||||
'下发 %d 条 = 计划 %d 条 - 与源逐字节相同的 %d 条 %s + canary'
|
||||
% (len(emit), len(exp), len(dropped), dropped))
|
||||
ck(len(pay) == len(rb) == len(emit), 'pay %d 条 / rb %d 条(payload.json %d 条)'
|
||||
% (len(pay), len(rb), len(emit)))
|
||||
ck(len(pay) > 0 and len(pay) == len(rb), '两张表条数相同')
|
||||
|
||||
print('== 逐条字节比对(负载 vs 改后镜像、回滚 vs 原厂镜像)==')
|
||||
stock = open(STOCK, 'rb').read()
|
||||
patched = open(PATCHED, 'rb').read()
|
||||
ck(len(stock) == len(patched) and stock[:8] == b'-rom1fs-', '两个整分区镜像等长且是 ROMFS')
|
||||
bad, misalign, manifest_bad = 0, 0, 0
|
||||
for i, ((po, pt, pl), (ro, rt, rl)) in enumerate(zip(pay, rb)):
|
||||
if not (po and ro) or (pt, pl) != (rt, rl):
|
||||
manifest_bad += 1
|
||||
continue
|
||||
if pt % 4 or pt + pl > len(stock): # RLE 素材的长度可以是任意值,只有偏移必须对齐
|
||||
misalign += 1
|
||||
if (face[po:po + pl] != patched[pt:pt + pl] or len(face[po:po + pl]) != pl
|
||||
or face[ro:ro + pl] != stock[pt:pt + pl] or len(face[ro:ro + pl]) != pl):
|
||||
bad += 1
|
||||
print(' FAIL #%d %s off=0x%x len=%d'
|
||||
% (i, emit[i]['name'] if i < len(emit) else '?', pt, pl))
|
||||
ck(bad == 0, '%d 条:负载 == 改后镜像,回滚 == 原厂镜像' % len(pay))
|
||||
ck(misalign == 0, '全部 4 对齐且不越界')
|
||||
ck(manifest_bad == 0, 'pay/rb 的目标偏移与长度一一对应')
|
||||
names_in_lua = [e['name'] for e in emit]
|
||||
plan_only = [r['name'] for r in exp if r['name'] not in dropped]
|
||||
ck(names_in_lua[1:] == plan_only, '下发名单(去掉 canary)== 计划名单(去掉 %s)' % dropped)
|
||||
|
||||
print('== 负载名单 ==')
|
||||
names = [r['name'] for r in exp]
|
||||
ck('voice_aivs' in names, 'voice_aivs 在负载里')
|
||||
ck(sorted(names) == sorted(set(names)), '没有重名记录')
|
||||
|
||||
print('== voice_aivs 的字节 == 用户 PNG 重编码 ==')
|
||||
rec = next(r for r in exp if r['name'] == 'voice_aivs')
|
||||
r = Romfs(STOCK)
|
||||
sheet = {p: i for p, i in r.entries() if p == 'voice/aivs/launcher.bin'}
|
||||
ino = sheet['voice/aivs/launcher.bin']
|
||||
ck(ino.data_off == rec['offset'] and ino.size == rec['length'],
|
||||
'manifest 偏移/长度 == ROMFS inode(0x%08x / %d)' % (ino.data_off, ino.size))
|
||||
h, stock_img = decode_lvgl_bin(r.read(ino))
|
||||
|
||||
|
||||
def ink_bbox(img, thr=16):
|
||||
a = img.convert('RGBA').getchannel('A'); w, hh = a.size; px = a.load()
|
||||
x0, y0, x1, y1 = w, hh, -1, -1
|
||||
for y in range(hh):
|
||||
for x in range(w):
|
||||
if px[x, y] > thr:
|
||||
x0 = min(x0, x); y0 = min(y0, y); x1 = max(x1, x); y1 = max(y1, y)
|
||||
return None if x1 < 0 else (x0, y0, x1, y1)
|
||||
|
||||
|
||||
sp = os.path.join(PROJ, 'assets', 'override', 'voice_aivs.png')
|
||||
src = Image.open(sp).convert('RGBA')
|
||||
sb = ink_bbox(stock_img)
|
||||
target = max(sb[2] - sb[0] + 1, sb[3] - sb[1] + 1)
|
||||
nb = ink_bbox(src)
|
||||
art = src.crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1)).resize((target, target), Image.LANCZOS)
|
||||
cv = Image.new('RGBA', (h['w'], h['h']), (0, 0, 0, 0))
|
||||
cv.paste(art, ((h['w'] - target) // 2, (h['h'] - target) // 2))
|
||||
want = ip.encode(cv, h['cf'], h['w'], h['h'], h['flags'])
|
||||
ck(len(want) == ino.size, '按槽位格式重编码 %d B == 槽位 %d B' % (len(want), ino.size))
|
||||
got = [(o, t, l) for (o, t, l) in pay if t == rec['offset']]
|
||||
ck(bool(got) and face[got[0][0]:got[0][0] + rec['length']] == want,
|
||||
'交付物里 voice_aivs 的负载字节 == 用户 PNG 重编码结果')
|
||||
ck(patched[ino.data_off:ino.data_off + ino.size] == want, '改后整分区镜像里也是同一份字节')
|
||||
ck(stock[ino.data_off:ino.data_off + ino.size] != want, '与 S4 原厂字节确实不同')
|
||||
ck(os.path.basename(sp) == 'voice_aivs.png' and
|
||||
hashlib.sha256(open(sp, 'rb').read()).hexdigest()[:16] == '362166b54ac03f2b',
|
||||
'override 源图 sha256 362166b54ac03f2b(= 用户给的 smallai.png)')
|
||||
|
||||
print('== ROMFS 结构未被破坏 ==')
|
||||
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(STOCK).entries()]
|
||||
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(PATCHED).entries()]
|
||||
ck(a == b, 'inode 表逐位不变(%d 条)' % len(a))
|
||||
ck(Romfs(STOCK).sb_size == Romfs(PATCHED).sb_size and
|
||||
Romfs(STOCK).sb_cksum == Romfs(PATCHED).sb_cksum, 'superblock 不变')
|
||||
r2 = Romfs(PATCHED)
|
||||
i1 = {p: i for p, i in Romfs(STOCK).entries()}
|
||||
i2 = {p: i for p, i in r2.entries()}
|
||||
changed = sorted(p for p in i1 if Romfs(STOCK).read(i1[p]) != r2.read(i2[p]))
|
||||
ck(len(changed) == len(pay) - 1, '真正变化的文件 %d 个(负载 %d 条,其中 canary 是分区头)'
|
||||
% (len(changed), len(pay)))
|
||||
|
||||
print('== 负载/回滚文件在 .face 里连续存放 ==')
|
||||
pfiles = [(f, open(os.path.join(PROJ, 'payload', f), 'rb').read())
|
||||
for f in sorted(os.listdir(os.path.join(PROJ, 'payload'))) if f.endswith('.bin')]
|
||||
rbfiles = [(f, open(os.path.join(PROJ, 'payload_rollback', f), 'rb').read())
|
||||
for f in sorted(os.listdir(os.path.join(PROJ, 'payload_rollback'))) if f.endswith('.bin')]
|
||||
ck(len(pfiles) == len(pay), 'payload/ 里 %d 个文件 == %d 条' % (len(pfiles), len(pay)))
|
||||
hit_pay = sum(1 for (o, t, l) in pay if any(face[o:o + l] == d for _, d in pfiles))
|
||||
hit_rb = sum(1 for (o, t, l) in rb if any(face[o:o + l] == d for _, d in rbfiles))
|
||||
ck(hit_pay == len(pay), '每条负载都能在 payload/ 里找到同名同字节的文件(%d/%d)' % (hit_pay, len(pay)))
|
||||
ck(hit_rb == len(rb), '每条回滚都能在 payload_rollback/ 里找到(%d/%d)' % (hit_rb, len(rb)))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print('!! %d 项未通过' % len(fails))
|
||||
sys.exit(1)
|
||||
print('全部通过 —— %s (%d B, sha256 %s)' % (os.path.basename(FACE), len(face),
|
||||
hashlib.sha256(face).hexdigest()[:16]))
|
||||
Reference in New Issue
Block a user