# -*- coding: utf-8 -*- """把 S5 41mm(q63) 的充电动画搬进 S5 eSIM 46mm(p62lte) 的 /dev/system(逐帧等长覆盖)。 目标:inputs/target/vela_system.bin charging/charge0..60.bin(**480x480** I8+RLE,61 帧) 源 :inputs/source/vela_system.bin charging/charge0..60.bin(**464x464** I8+RLE,61 帧) 映射:**帧号 1:1(n -> n)**。S4e 那版是 57 帧按比例重采样(n -> round(n/56*60)), 这一版两边都是 61 帧,**不要照抄那个比例** —— 逐帧对应才不会出现错帧/跳帧。 放大与编码策略(按优先级逐级降级,选第一条塞进槽位的): 1. 原调色板 + Lanczos:把源帧用 Lanczos 放大到 480x480(RGB),再把每个像素**吸附回源帧 自己的调色板**(256 项里找最近的)。颜色只可能来自源帧的调色板 —— 空间上比最近邻放大 平滑,颜色上零新增。实测 61/61 帧都塞得进,且体积与最近邻放大几乎一样(0.996x)。 2. 原调色板 + Lanczos + 暗区合并:把亮度 < T 的像素全归到一个最暗的索引上换 RLE 长度, T 从小到大试(只影响接近黑的雾面区,亮环不动)。这是兜底,本项目实际没用上。 3. 原调色板 + 最近邻索引放大(保底、无插值)。 4. 整帧重新量化(颜色数从 256 逐级往下试)—— 最后兜底。 等长要点(这是命根子): 1) 数据总长必须**恰好**等于原槽位长度(ROMFS 后面所有偏移都是绝对值) 2) 解码长度必须**恰好**等于 usize(少一字节设备端整帧不画;多一字节越界 -> INVALID) —— usize_tail 用「重复最后一个字节」补齐,正是因为踩过「RLE 输出比 usize 少 1 个像素」的坑 3) pad_rle_safe 只用「产出 0 像素」的合法空指令(00 00 / 80)补齐到 csize """ import os, sys, json, struct, shutil, hashlib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from paths import INPUTS, WORK, DOCS from romfs import Romfs import lvgl_bin as L from PIL import Image import numpy as np STOCK = os.path.join(INPUTS, 'target', 'vela_system.bin') # S5 eSIM 46mm (p62lte) SOURCE = os.path.join(INPUTS, 'source', 'vela_system.bin') # S5 41mm (q63) OUTDIR = os.path.join(WORK, 'charge') PATCHED = os.path.join(OUTDIR, 'vela_system_charge.bin') DIRP = 'charging/' W = H = 480 SAMPLE = None if (len(sys.argv) > 1 and sys.argv[1] == 'all') else [0, 14, 28, 42, 56] THRS = (4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 96, 128, 192, 255) FALLBACK_COLORS = (256, 192, 128, 96, 64, 48, 32) DRAW_DOCS = True def num_of(path): return int(path.rsplit('charge', 1)[-1].replace('.bin', '')) def collect(rom): d = {} for p, i in rom.entries(): if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin'): continue d[num_of(p)] = (p, i, rom.read(i)) return d def decode_fast(blob): """(header, palette 256x4 BGRA, index HxW) —— 用库里的 rle_decompress,索引取出用 numpy。""" h = L.parse_header(blob) method, csize, usize = struct.unpack(' Lanczos 放大到 (w,h) 的 RGB(质量基准,也用于吸附)。""" im = Image.fromarray(src_rgb(pal, idx).astype(np.uint8), 'RGB').resize((w, h), Image.LANCZOS) return np.asarray(im, np.int32) def snap(rgb, pal, chunk=8192): """每个像素在 256 项调色板里的最近色索引(欧氏距离,分块 brute force)。""" p = rgb_tab(pal) flat = rgb.reshape(-1, 3).astype(np.int32) out = np.empty(len(flat), np.uint8) for i in range(0, len(flat), chunk): d = flat[i:i + chunk, None, :] - p[None, :, :] out[i:i + chunk] = np.argmin((d * d).sum(axis=2), axis=1) return out.reshape(rgb.shape[:2]) def nn_idx(idx, w, h): """最近邻索引放大(不做任何插值)。""" sh, sw = idx.shape ys = np.clip(np.floor((np.arange(h) + 0.5) * sh / h).astype(np.int64), 0, sh - 1) xs = np.clip(np.floor((np.arange(w) + 0.5) * sw / w).astype(np.int64), 0, sw - 1) return idx[ys[:, None], xs[None, :]] def dark_index(pal, idx, thr): """暗区里选一个代表索引:取其中颜色最暗的那个。""" vals = np.unique(idx[np.asarray(rgb_tab(pal)[idx].max(axis=2) < thr)]) if len(vals) == 0: return None return int(vals[rgb_tab(pal)[vals].max(axis=1).argmin()]) def pal4(q): pal = list(q.getpalette() or []) + [0] * (768 - len(q.getpalette() or [])) out = bytearray() for k in range(256): out += bytes((pal[k * 3 + 2], pal[k * 3 + 1], pal[k * 3], 255)) return bytes(out) def usize_tail(comp, raw_len, usize): """把「解码出来的裸字节数」补到恰好 usize。 踩过的坑:RLE 输出比 usize **少一个像素**时设备端整帧不画。这里的补法是 「再重复最后一个字节 N 次」(ctrl=N, unit=last)—— 视觉上只是把最后一个像素的 游程延长,不会像补 0 那样在末尾画一条黑边;也不会多解(正好补齐 usize)。 """ short = usize - raw_len if short <= 0: return comp unit = comp[-1] if comp else 0 ex = bytearray() while short > 0: n = min(short, 127) ex += bytes((n, unit)) short -= n return comp + bytes(ex) def encode_frame(pal, idx, w, h, csize, usize): """返回 (comp, idx_out, how)。comp 已补到「恰好解码出 usize 字节」。""" ref = lanczos_ref(pal, idx, w, h) # --- 1) 原调色板 + Lanczos 吸附(本项目 61/61 都走这条) cand_idx = snap(ref, pal) for thr in (0,) + THRS: if thr <= 0: i2 = cand_idx else: m = ref.max(axis=2) < thr if not m.any(): i2 = cand_idx else: di = dark_index(pal, cand_idx, thr) i2 = cand_idx if di is None else np.where(m, di, cand_idx).astype(np.uint8) c = L.rle_compress(pal.tobytes() + i2.tobytes()) c = usize_tail(c, 1024 + w * h, usize) if len(c) <= csize: how = '原调色板+Lanczos' if thr <= 0 else '原调色板+Lanczos+暗区<%d' % thr return c, i2, how # --- 2) 最近邻索引放大(无插值) i2 = nn_idx(idx, w, h) c = usize_tail(L.rle_compress(pal.tobytes() + i2.tobytes()), 1024 + w * h, usize) if len(c) <= csize: return c, i2, '原调色板+最近邻放大' # --- 3) 整帧重新量化,颜色数逐级往下 for ncol in FALLBACK_COLORS: q = Image.fromarray(ref.astype(np.uint8), 'RGB').quantize( colors=ncol, method=Image.MEDIANCUT, dither=Image.NONE) i2 = np.asarray(q, np.uint8) c = usize_tail(L.rle_compress(pal4(q) + i2.tobytes()), 1024 + w * h, usize) if len(c) <= csize: return c, i2, '重新量化 %d 色' % ncol raise AssertionError('帧塞不进槽位(连 %d 色都不行)' % FALLBACK_COLORS[-1]) def main(): os.makedirs(OUTDIR, exist_ok=True) stock, src = Romfs(STOCK), Romfs(SOURCE) slots, srcs = collect(stock), collect(src) tl, sl = sorted(slots), sorted(srcs) print('目标 S5 eSIM(p62lte) %d 帧(%d..%d,槽位合计 %.2f MB)' % ( len(tl), tl[0], tl[-1], sum(len(slots[n][2]) for n in tl) / 1048576)) print('源 S5 41mm(q63) %d 帧(%d..%d,%.2f MB)' % ( len(sl), sl[0], sl[-1], sum(len(srcs[n][2]) for n in sl) / 1048576)) assert tl == sl, '两边帧号不一致,不能做 1:1 覆盖' todo = tl if SAMPLE is None else [n for n in SAMPLE if n in slots] print('映射:n -> n(1:1) 本次要改的帧:%s' % todo) print('输出分辨率:%dx%d(源 464x464 -> Lanczos 放大后吸附回源调色板)\n' % (W, H)) records, decode_ck, used, samples = [], 0, {}, [] for n in todo: path, ino, blob = slots[n] h = L.parse_header(blob) w, ht, stride = h['w'], h['h'], max(h['stride'], h['w']) method, csize, usize = struct.unpack('