# -*- coding: utf-8 -*- """把 S5 41mm(q63) 的 `pressure/measure/Measuring1..100.bin`(480x480)逐帧等长写进 S5 eSIM(p62lte) `vela_health.bin` 的 `pressure/measure/Measuring1..119.bin`(464x464 I8+RLE)。 硬约束(为什么必须"逐字节等长") -------------------------------- ROMFS 里 `next` / `spec` 全是**绝对偏移**,文件数据又紧随 inode 之后;任何一条负载 长度变化 1 字节,后面所有文件的偏移就整体平移 → 分区报废。所以: 12 B 头 + 12 B 压缩头 + csize 必须 == 原槽位长度 csize 原样沿用(解码器按 csize 读),usize 原样沿用(= 1024 + 464*464 = 216321) 四步流水线 ---------- 1) 解码 q63 帧 → 缩放到 464x464(**缩小**,LANCZOS) 2) 暗部归零:q63 帧的"黑"其实是一条均匀的 #1a1a1a(26,26,26) 底噪 + 抖动噪声, 实测占 68% 的像素、熵极高(256 色几乎全糊在暗部,不处理时 RLE 压不动:172 KB)。 阈值以下的像素一律置 0,背景塌成整行单游程,体积掉到 1/4(~45 KB)。 3) 量化:按一条**单调质量阶梯**(缩放比例 + 颜色数一起动)挑能塞进 csize 的最高档。 4) LVGL RLE 压缩 → 先补「重复 N 次」把解码长度做到**正好 usize**, 再用 `pad_rle_safe` 的合法空指令(00 00 / 80)补齐到 csize。 为什么用"质量单参数"而不是逐槽各自最优 -------------------------------------- 槽位预算从 4.5 KB 到 196 KB 差 40 倍。逐帧各自最优会让相邻帧出现 「2 色 x0.32」跳到「4 色 x0.80」这种忽大忽小(S4e 那版踩过)。这里把 (颜色数, 缩放) 绑成一个连续量,先算每槽能撑住的最大档,再从峰值向两侧取 「运行最小值」—— 曲线单峰且平滑,画面尺寸/色阶随预算连续变化。 产出 ---- .work/anim/vela_health_anim.bin 改后镜像(长度 == 原镜像) .work/anim/manifest.json 逐条清单(偏移/长度/映射/降级/保留) """ import os, sys, json, struct, shutil, hashlib, time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from paths import (PROJ, INPUTS, WORK, TARGET_IMAGE, SOURCE_IMAGE, TARGET_VOLUME, DIRP, FACE_W, FACE_H) from romfs import Romfs import lvgl_bin as L from PIL import Image import numpy as np OUTDIR = os.path.join(WORK, 'anim') PATCHED = os.path.join(OUTDIR, 'vela_health_anim.bin') # 设备端解码器要求流解出的字节数**正好** == 头里的 usize。原厂 119 条全是 216321, # 比"调色板 + 像素"多 1 字节(解码器只用到前 1024 + 464*464 字节,多出来的 1 字节 # 落在行外、被 `y < h` 挡掉,所以既安全又是原厂行为)。实测值由 build() 从镜像里读出来, # 不在这儿写常量 —— 写错过一次(写成 216320)就被下面的断言当场抓住。 USIZE = 1024 + FACE_W * FACE_H # 216320:调色板 + 像素;真实值以镜像为准 TAIL_EXTRA = 1 # 期望的原厂尾部额外字节数 USIZE_EXTRA = TAIL_EXTRA # 实际值由 build() 从镜像读出来后覆盖 # ------------------------------------------------------------------ 暗部归零 # q63 帧的底噪是 #1a1a1a(26),球体最暗的实体像素也 >100。取 40 吃掉整片底噪与抖动, # 又不动球体本体;56 作为第二档兜底(更狠,留给塞不下的槽位)。 DARK_LEVELS = (40, 56) # ------------------------------------------------------------------ 固定调色板(低预算兜底) # 到 8 色以下时"重新聚类"会把仅剩的颜色浪费在底噪残渣上、球体反而变花; # 改用这几套手工调色板(索引 0 一定是黑),画面干净、也更好压。 FIXED_PALETTES = { 2: [(0, 0, 0), (92, 132, 255)], 3: [(0, 0, 0), (40, 60, 190), (120, 190, 255)], 4: [(0, 0, 0), (28, 40, 150), (70, 120, 245), (140, 220, 210)], 6: [(0, 0, 0), (18, 26, 110), (44, 66, 200), (80, 130, 250), (140, 200, 245), (150, 220, 160)], 8: [(0, 0, 0), (12, 18, 80), (26, 38, 140), (46, 70, 195), (72, 110, 240), (110, 165, 252), (165, 215, 250), (150, 222, 168)], } # ------------------------------------------------------------------ 质量阶梯 # 从「最好」到「最省」单调排列:(缩放, 颜色数, 固定调色板色数|None) LADDER = [ (1.00, 256, None), (1.00, 192, None), (1.00, 128, None), (1.00, 96, None), (0.95, 96, None), (0.95, 64, None), (0.90, 64, None), (0.85, 64, None), (0.85, 48, None), (0.80, 48, None), (0.75, 48, None), (0.75, 32, None), (0.70, 32, None), (0.65, 32, None), (0.65, 24, None), (0.60, 24, None), (0.55, 24, None), (0.55, 16, None), (0.50, 16, None), (0.45, 16, None), (0.45, 12, None), (0.40, 12, None), (0.35, 12, None), (0.35, 8, 8), (0.30, 8, 8), (0.26, 6, 6), (0.22, 6, 6), (0.19, 4, 4), (0.16, 4, 4), (0.13, 3, 3), (0.11, 3, 3), (0.09, 2, 2), (0.07, 2, 2), (0.05, 2, 2), ] CI_KEEP = len(LADDER) # 哨兵:连最省档都塞不下 # 平滑之后允许比目标档**大**多少缩放:0 表示只许更小。留一点余量是为了让球体尽量顶到 # 预算上限,同时不给相邻帧制造肉眼可见的尺寸跳变(每档缩放差约 0.05)。 SCALE_SLACK = 0.02 def num_of(path): return int(path.rsplit('Measuring', 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') or i.size == 0: continue d[num_of(p)] = (p, i, rom.read(i)) return d # ------------------------------------------------------------------ 编码原语 def palette_bytes(cols): """1024 B BGRA;索引 0 一定是黑,没用的条目留 0 —— 调色板本身也好压。""" out = bytearray(1024) for k, (r, g, b) in enumerate(cols[:256]): out[k * 4:k * 4 + 4] = bytes((b, g, r, 255)) return bytes(out) def kill_dark(img, dark): a = np.asarray(img.convert('RGB')).copy() a[a.max(axis=2) < dark] = 0 return Image.fromarray(a, 'RGB') def scale_image(img, sc): if sc >= 0.999: return img s = max(4, int(round(FACE_W * sc))) small = img.convert('RGBA').resize((s, s), Image.LANCZOS) cv = Image.new('RGBA', (FACE_W, FACE_H), (0, 0, 0, 255)) cv.paste(small, ((FACE_W - s) // 2, (FACE_H - s) // 2)) return cv def quantize_raw(rgb, ncol, fixed): """-> 1024 B 调色板 + w*h 个索引(LVGL I8 的裸像素流)。 自己实现量化,不再借 PIL 的调色板 API —— 这里踩过两个坑,留作警钟: ① 为了"保证索引 0 是黑",先把图贴到 (W+8)x(H+8) 黑画布上再 quantize, 然后 `q.tobytes()[:W*H]` 取像素。但 q 是 472 宽的,按 464 宽读就"每行错 8 列", 整幅图被剪成斜条纹 —— RLE 流完全正确、解出来也和"量化结果"逐点一致, 只有拿"量化结果的形状"去比**源图**才抓得住(见 silhouette())。 ② 改成"循环移位调色板 + convert()/paste()"更糟:`quantize(palette=...)` 会把图重新 合并成它自己算出来的调色板,`paste()` 又只搬调色板,于是 64 色时整幅图变纯黑。 与其猜 PIL 的语义,不如把量化写成几行明确的 numpy。 现在:黑单独占索引 0(背景因此塌成单游程),其余 ncol-1 个颜色在**非黑像素**上做 加权 k-means(按出现次数加权,梯度才不会被子像素级的近似黑拖偏)。固定迭代次数 => 确定。 """ src = np.asarray(rgb if rgb.mode == 'RGB' else rgb.convert('RGB')) return quantize_arr(src, ncol, fixed) def quantize_arr(src, ncol, fixed, cache=None, ckey=None): """量化主体。src 是 (H,W,3) uint8;ckey 用来复用同一帧的颜色表。 性能关键(第一版在这里把机器跑爆过内存 1.7 GB / CPU 卡死): * 源图有 1.5~2 万个不同颜色、21.5 万个像素。**质心只在"不同颜色"上算**, 绝不在 21.5 万像素上做 (N, K, 3) 的距离广播 (那一步就是 N=215296 x K=255 的模式,一个临时数组 1.6 GB)。 * 像素 -> 索引 走一张**粗查色立方** LUT:先把 RGB 量化到 step 网格 (step=4 时 64³ = 262144 个格子),只对这 26 万个格子算一次最近邻, 再让 21.5 万像素直接查表。代价是"近似最近邻"(最大偏 step/2 = 2 个色阶), 对 8 位调色板完全够用。 """ H, W = src.shape[:2] if cache is None: cache = {} ent = cache.get(ckey) if ent is None: flat = src.reshape(-1, 3) bg = flat.max(axis=1) == 0 upix, inv = np.unique(flat, axis=0, return_inverse=True) upix = np.ascontiguousarray(upix, dtype=np.int32) inv = inv.reshape(-1).astype(np.int32) fg = np.nonzero(upix.max(axis=1) > 0)[0] ent = (upix, inv, fg, bg, flat.shape[0]) cache[ckey] = ent upix, inv, fg, bg, npix = ent if len(fg) == 0: return palette_bytes([(0, 0, 0)]) + bytes(npix) if fixed is not None: cols = FIXED_PALETTES[fixed] out = _lut_index(upix, cols)[inv] out[bg] = 0 # 背景必须落在索引 0(黑),这样 RLE 才是一条单游程 if cols[0] != (0, 0, 0): raise ValueError('固定调色板的索引 0 必须是黑') return palette_bytes(cols) + out.tobytes() k = max(1, min(255, int(ncol) - 1)) ufg = upix[fg] uq, cnt = np.unique(ufg, axis=0, return_counts=True) if len(uq) <= k: centers = uq.astype(np.float64) else: centers = uq[cnt.argsort()[::-1][:k]].astype(np.float64) # 出现最多的 k 色做种子 w = cnt.astype(np.float64) K = len(centers) for _ in range(8): lab = _nearest_argmin(uq, centers) new = centers.copy() for j in range(K): sel = lab == j if sel.any(): ww = w[sel][:, None] new[j] = (uq[sel] * ww).sum(axis=0) / ww.sum() done = np.allclose(new, centers, atol=0.25) centers = new if done: break cols = [(0, 0, 0)] + [tuple(int(round(v)) for v in c) for c in centers] out = _lut_index(upix, cols)[inv] out[bg] = 0 return palette_bytes(cols) + out.tobytes() def _nearest_argmin(px, cent): """返回每个 px 最近的 cent 下标;只用 float32(int32 的立方广播会反复申请上百 MB)。""" a = np.asarray(px, dtype=np.float32) c = np.asarray(cent, dtype=np.float32) out = np.empty(len(a), dtype=np.int32) chunk = 8192 for s in range(0, len(a), chunk): d = ((a[s:s + chunk, None, :] - c[None, :, :]) ** 2).sum(axis=2) out[s:s + chunk] = d.argmin(axis=1) return out _GRID_CACHE = {} def _cube_grid(step): """粗查色立方的网格代表色(只算一次,全局缓存)。 展平顺序是 idx = r*(bins*bins) + g*bins + b —— 与 `meshgrid(indexing='ij')` + `reshape(-1,3)` 完全一致,所以立方体可以直接按这个公式索引。 ★ 轴序写错会静默换色:查表必须用 (R,G,B) 顺序,写成 (B,G,R) 颜色就全串了。 """ g = _GRID_CACHE.get(step) if g is None: bins = 256 // step + 1 ax = np.arange(bins, dtype=np.int32) * step grid = np.stack(np.meshgrid(ax, ax, ax, indexing='ij'), axis=-1).reshape(-1, 3) _GRID_CACHE[step] = (grid, bins) return _GRID_CACHE[step] return g def _lut_index(px, cols): """像素 -> 最近调色板项,走粗查色立方 LUT。 step=8 → 33³ = 35937 个格子,单通道最大偏差 step/2 = 4 个色阶; 对这些低对比度、本来就带抖动的画面完全够用(step=4 的 26 万格子慢 8 倍)。 用 float32 算距离:int32 的 (35937, 256, 3) 中间数组会反复申请 ~100 MB, 是之前把内存跑到 2.6 GB、整体跑十几分钟的元凶。 """ grid, bins = _cube_grid(8) pal = np.asarray(cols, dtype=np.float32) best = np.empty(len(grid), dtype=np.uint8) gn = grid.astype(np.float32) chunk = 8192 for s in range(0, len(grid), chunk): a = gn[s:s + chunk] d = ((a[:, None, :] - pal[None, :, :]) ** 2).sum(axis=2) best[s:s + chunk] = d.argmin(axis=1).astype(np.uint8) q = np.clip((px.astype(np.int32) + 4) >> 3, 0, bins - 1) flat = (q[:, 0] * bins + q[:, 1]) * bins + q[:, 2] return best[flat] def pad_to_usize(comp, raw_len): """解码长度必须**正好 == usize**,否则设备端解码器直接判失败、整帧不画。 ★ 本会话踩过的坑:库里 rle_compress 的输出曾比 usize 少 1 个字节(末尾少一条指令)。 这里显式补「重复 N 次」token 把长度做满。补出来的余数可能是奇数(原厂 usize 就是 1024+464*464+1 = 216321,比"调色板+像素"多 1),所以最后允许一条「重复 1 次」的 2 字节指令;`00 00`(重复 0 次)也是合法空指令。 """ short = USIZE - raw_len if short <= 0: return comp ex = bytearray() while short >= 2: k = min(short, 127) ex += bytes((k, 0)) short -= k if short == 1: ex += bytes((1, 0)) return comp + bytes(ex) def encode_cell(img, cell, qcache=None, qkey=None): """(缩放, 颜色数, 固定) -> (RLE 流 bytes, 裸像素流 bytes)。不含 24 B 头。 qkey 要带上**缩放比例**:同一帧的不同缩放是不同的数组, ★ 少了这个维度会让"不同颜色的表"永远命中不了缓存,每次都在 21.5 万像素上重算 (第一版就是这样把内存跑到 1.7 GB、CPU 卡死的)。 """ sc, ncol, fixed = cell rgb = scale_image(img, sc).convert('RGB') raw = quantize_arr(np.asarray(rgb), ncol, fixed, qcache, (qkey, sc) if qkey is not None else None) return pad_to_usize(L.rle_compress(raw), len(raw)), raw def raw_to_rgb(raw): """把裸像素流还原成 RGB 图(用于"解出来的像素 == 量化结果"这条自证)。""" pal = bytearray(768) for i in range(256): pal[i * 3 + 0] = raw[i * 4 + 2] pal[i * 3 + 1] = raw[i * 4 + 1] pal[i * 3 + 2] = raw[i * 4 + 0] im = Image.frombytes('P', (FACE_W, FACE_H), raw[1024:1024 + FACE_W * FACE_H]) im.putpalette(bytes(pal)) return im.convert('RGB') def silhouette(raw): """(非黑像素数, bbox) —— 用来挡住"字节完全自洽、内容却整片错位"这类 bug。 ★ 这里就是因为只比字节、不比形状,差点放过去一版"斜条纹"图: 当时 quantize 是按 472 宽取的像素,读成 464 宽 → 每行错 8 列 → 整幅图被剪成斜条纹。 RLE 流本身完全正确、解出来的像素也和"量化结果"逐点一致,所以只有把 "量化结果"再和**源图**比形状才抓得住。下面这个函数就是干这个的。 """ a = np.asarray(raw_to_rgb(raw)) m = a.max(axis=2) > 24 n = int(m.sum()) if not n: return 0, None ys, xs = np.nonzero(m) return n, (int(ys.min()), int(xs.min()), int(ys.max()), int(xs.max())) # ------------------------------------------------------------------ 主流程 def build(): t0 = time.time() global USIZE, USIZE_EXTRA os.makedirs(OUTDIR, exist_ok=True) stock, src = Romfs(TARGET_IMAGE), Romfs(SOURCE_IMAGE) if stock.volume != TARGET_VOLUME: raise SystemExit('目标镜像卷名是 %r,期望 %r —— 拿错分区了?' % (stock.volume, TARGET_VOLUME)) slots, srcs = collect(stock), collect(src) tl, sl = sorted(slots), sorted(srcs) h0 = L.parse_header(slots[tl[0]][2]) print('目标槽位 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % ( len(tl), tl[0], tl[-1], sum(len(slots[n][2]) for n in tl) / 1048576)) print('源帧 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % ( len(sl), sl[0], sl[-1], sum(len(srcs[n][2]) for n in sl) / 1048576)) print('槽位格式 %dx%d cf=0x%02x flags=0x%02x stride=%d' % ( h0['w'], h0['h'], h0['cf'], h0['flags'], h0['stride'])) assert (h0['w'], h0['h']) == (FACE_W, FACE_H), '槽位不是 %dx%d' % (FACE_W, FACE_H) assert (h0['cf'], h0['flags']) == (0x0a, 0x08), '槽位不是 I8 + RLE' # ★ usize 从镜像里读出来,别写常量:原厂是 1024 + 464*464 + 1 = 216321 n_usize = {struct.unpack(' (comp, raw) src_sils = {} # (src_n, dark, ci) -> (非黑像素数, bbox) qcache = {} def get_cell(src_n, dark, ci): key = (src_n, dark, ci) v = cells.get(key) if v is None: v = encode_cell(frames[src_n][dark], LADDER[ci], qcache, (src_n, dark)) cells[key] = v src_sils[key] = silhouette(v[1]) return v def fits(src_n, dark, ci, csize): return len(get_cell(src_n, dark, ci)[0]) <= csize # ---- 第一遍:逐槽量出「能塞进 csize 的最大阶梯档」---- caps = [] for k, n in enumerate(tl): csize = struct.unpack(' best[1]: best = ((ci, dark), key) rec_name = '%03d_%s_%d' % (len(records), path.rsplit('/', 1)[-1][:-4], n) if best is None: kept.append((n, csize)) records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off, length=len(blob), data=blob, csize=csize, how='保留原厂', ci=None, dark=None, scale=1.0, colors=0, pad=0, enc=0)) print(' !! 槽 %d(csize %d)塞不下 -> 保留原厂' % (n, csize)) continue (ci, dark), _key = best comp, raw = get_cell(src_n, dark, ci) new = blob[:12] + struct.pack('= 0.995 else ' 缩放%.2f' % sc how += ' 暗部<%d' % dark if ci > start: degrade.append((n, csize, start, ci, how)) how += ' [平滑目标第%d档放不下,退到第%d档]' % (start, ci) records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off, length=len(blob), data=new, csize=csize, how=how, ci=ci, dark=dark, scale=round(sc, 4), colors=ncol, fixed=fixed, pad=csize - len(comp), enc=len(comp), target_ci=start)) # ---- 写出镜像 + 清单 ---- shutil.copyfile(TARGET_IMAGE, PATCHED) with open(PATCHED, 'r+b') as f: for r in records: f.seek(r['offset']) f.write(r['data']) manifest = dict( device='Xiaomi Watch S5 eSIM 46mm (p62lte)', partition='vela_health.bin volume=health device node=/dev/health', source='Xiaomi Watch S5 41mm (q63, v4.101.020) pressure/measure frames', target_dir=DIRP, mapping='第 k 个槽位(k=1..%d) <- q63 第 round((k-1)/(%d-1)*(%d-1))+1 帧(1..%d)' % (len(tl), len(tl), len(sl), len(sl)), face_wh=[FACE_W, FACE_H], slot_count=len(tl), source_count=len(sl), usize=USIZE, image_sha256=hashlib.sha256(stock.data).hexdigest(), ladder=[dict(ci=i, scale=c[0], colors=c[1], fixed=c[2]) for i, c in enumerate(LADDER)], records=[dict(name=r['name'], offset=r['offset'], length=r['length'], src=r['src'], how=r['how'], ci=r['ci'], dark=r['dark'], scale=r['scale'], colors=r['colors'], fixed=r['fixed'], pad=r['pad'], enc=r['enc'], target_ci=r['target_ci'], csize=r['csize']) for r in records], kept_stock=[dict(n=n, csize=c, name=slots[n][0]) for n, c in kept], degraded=[dict(n=n, csize=c, from_ci=a, to_ci=b, how=h) for n, c, a, b, h in degrade]) json.dump(manifest, open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False) # ---- 汇总 ---- print('\n阶梯使用情况(颜色数, 缩放, 暗部 -> 帧数):') used = {} for r in records: if r['ci'] is None: continue k2 = (r['colors'], r['scale'], r['dark']) used[k2] = used.get(k2, 0) + 1 for kk, v in sorted(used.items(), key=lambda kv: (-kv[0][1], -kv[0][0])): print(' %3d 色 缩放%.2f 暗部<%d -> %3d 帧' % (kk[0], kk[1], kk[2], v)) print(' 保留原厂:%d 帧 %s' % (len(kept), [n for n, _ in kept])) print(' 平滑目标档放不下、被迫退档:%d 帧 %s' % (len(degrade), [(n, h) for n, _c, _a, _b, h in degrade][:8])) pads = [p[3] for p in stat] print(' 槽位填充:实际用掉 %.1f%% 的 csize(填充 min %d / max %d / 平均 %.0f B,' '全是合法空指令 00 00 / 80)' % (100.0 * sum(p[2] for p in stat) / sum(p[1] for p in stat), min(pads), max(pads), sum(pads) / len(pads))) print(' 回解自证:正好 usize 且像素一致 %d / %d' % (decode_ok, len(records) - len(kept))) # ---- 独立自证 ---- r2 = Romfs(PATCHED) a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()] b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in r2.entries()] print('\ninode 表逐位不变:%s(%d 条)' % (a == b, len(a))) print('superblock 一致:%s(size %d / cksum %08x)' % ( stock.sb_size == r2.sb_size and stock.sb_cksum == r2.sb_cksum, r2.sb_size, r2.sb_cksum)) print('镜像长度一致:%s(%d B)' % (len(stock.data) == len(r2.data), len(r2.data))) sh = open(PATCHED, 'rb').read() replay = all(sh[r['offset']:r['offset'] + r['length']] == r['data'] for r in records) print('回放(改后镜像 == 计划字节):%s' % replay) lens_ok = all(r['length'] == len(slots[int(r['name'].rsplit('_', 1)[1])][2]) for r in records) print('每条负载长度 == 原槽位长度:%s' % lens_ok) print('\n负载合计 %.2f MB;耗时 %.0fs;写出 %s' % (sum(r['length'] for r in records) / 1048576, time.time() - t0, os.path.join(OUTDIR, 'manifest.json'))) return manifest if __name__ == '__main__': only = sys.argv[1] if len(sys.argv) > 1 else 'all' if only not in ('all', 'replace', 'restore'): raise SystemExit('用法: s5e_anim.py [all|replace|restore]') build()