"""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 iconpack.py make [--pad] [--patched] iconpack.py apply """ 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(' 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(' 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(' 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('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(' {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__)