Files
2026-10-02 21:34:56 +08:00

448 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build / apply a system-app launcher-icon replacement for a Vela `vela_system.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_system.bin>
iconpack.py make <vela_system.bin> <pngdir> <outdir> [--pad] [--patched]
iconpack.py apply <vela_system.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__)