Files
o62lte-customising-plan/s4e-charge/tools/make_charge_payload.py
T

204 lines
9.3 KiB
Python
Raw Normal View History

2026-10-02 21:34:56 +08:00
# -*- coding: utf-8 -*-
"""把 S5 41mm(q63) 的充电动画搬进 S4 eSIM 的 system 分区(逐帧等长覆盖)。
目标:inputs/target/vela_system.bin 的 charging/charge0..56.bin(464x464 I8 RLE)
源 :inputs/source/vela_system.bin 的 charging/charge0..60.bin(464x464 I8 RLE)
编码策略(v2,2026-10-02 换掉旧方案):
旧方案把整帧重新量化成 256 色(MEDIANCUT)—— 结果**圆环本身被毁**:亮区平均差 27~52,
因为 MEDIANCUT 按像素数量分配调色板,而亮环只占少部分像素 -> 分不到几个颜色。
新方案**直接沿用 q63 原帧的调色板 + 索引**(零量化误差,圆环逐像素完全一致),
只把"很暗的像素"归到一个索引上换取 RLE 长度,阈值从 0 起逐档加大,取**能塞进槽位的最小阈值**。
阈值只影响接近黑的雾面区(4~32/255),亮环完全不动。
等长要点:
1) 数据总长必须等于原槽位长度;usize 尾巴必须补够(少一字节设备就不画)
2) pad_rle_safe 只用 00 00 / 80 这类空指令补齐
"""
import os, sys, json, struct, shutil, hashlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from paths import INPUTS, WORK
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') # S4 eSIM
SOURCE = os.path.join(INPUTS, 'source', 'vela_system.bin') # q63
OUTDIR = os.path.join(WORK, 'charge')
PATCHED = os.path.join(OUTDIR, 'vela_system_charge.bin')
DIRP = 'charging/'
SAMPLE = None if (len(sys.argv) > 1 and sys.argv[1] == 'all') else [0, 14, 28, 42, 56]
THRS = (0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 96, 128, 192, 255)
FALLBACK_COLORS = (256, 192, 128, 96, 64, 48, 32)
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 src_indexed(blob):
"""取出 I8 RLE 帧的原始调色板(256x4 BGRA)与索引图(H,W)。"""
h = L.parse_header(blob)
method, csize, usize = struct.unpack('<III', blob[12:24])
raw = L.rle_decompress(blob[24:24 + csize], 1, usize)
pal = np.frombuffer(raw[:1024], dtype=np.uint8).reshape(256, 4)
st = max(h['stride'], h['w'])
idx = np.frombuffer(raw[1024:1024 + st * h['h']], dtype=np.uint8).reshape(h['h'], st)[:, :h['w']]
return h, pal, idx
def enc_source_palette(pal, idx, idx2, w, h, stride):
data = idx2.tobytes() if stride == w else np.hstack(
[idx2, np.zeros((h, stride - w), dtype=np.uint8)]).tobytes()
return L.rle_compress(pal.tobytes() + data)
def dark_index(pal, idx, thr):
"""暗区里选一个代表索引:取其中颜色最暗的那个。"""
vals = np.unique(idx[idx < 256][np.asarray(pal[idx][:, :, :3].max(axis=2) < thr).ravel()]) \
if False else np.unique(idx[np.asarray(pal[idx][:, :, :3].max(axis=2) < thr)])
lums = pal[vals][:, :3].max(axis=1)
return int(vals[lums.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):
short = usize - raw_len
if short <= 0:
return comp
ex = bytearray()
while short > 0:
n = min(short, 127)
ex += bytes((n, 0))
short -= n
return comp + bytes(ex)
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('目标 %d 帧(%d..%d,%.1f MB) 源 %d 帧(%d..%d,%.1f MB)' % (
len(tl), tl[0], tl[-1], sum(len(slots[n][2]) for n in tl) / 1048576,
len(sl), sl[0], sl[-1], sum(len(srcs[n][2]) for n in sl) / 1048576))
todo = tl if SAMPLE is None else [n for n in SAMPLE if n in slots]
print('本次要改的帧:%s' % todo)
records, decode_ck, used, fallback = [], 0, {}, 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('<III', blob[12:24])
assert len(blob) == 24 + csize
m = sl[round((n - tl[0]) / max(1, tl[-1] - tl[0]) * (len(sl) - 1))]
sh, spal, sidx = src_indexed(srcs[m][2])
assert (sh['w'], sh['h']) == (w, ht), '源/目标尺寸不一致'
s_rgb = spal[sidx][:, :, [2, 1, 0]] # 原始调色板是 BGRA,转成 RGB 再比
lum = np.max(s_rgb, axis=2)
comp = keep = None
how = ''
for thr in THRS: # 首选:保原调色板,只压暗区
if thr <= 0:
idx2 = sidx
else:
dm = lum < thr
if not dm.any():
idx2 = sidx
else:
vals = np.unique(sidx[dm])
idx2 = np.where(dm, int(vals[pal_lum(spal, vals).argmin()]), sidx).astype(np.uint8)
c = enc_source_palette(spal, sidx, idx2, w, ht, stride)
c = usize_tail(c, 1024 + stride * ht, usize)
if len(c) <= csize:
comp, keep, how = c, spal[idx2][:, :, [2, 1, 0]].copy(), \
('原调色板' if thr == 0 else '原调色板+暗区压到 < %d' % thr)
break
if comp is None: # 兜底:旧方案(整帧重量化)
fallback += 1
im = Image.fromarray(np.dstack([s_rgb, np.full((ht, w), 255, np.uint8)]), 'RGBA')
a = np.asarray(im).copy()
dark = a[:, :, :3].max(axis=2) < 46
a[dark, 0] = a[dark, 1] = a[dark, 2] = 0
for ncol in FALLBACK_COLORS:
q = Image.fromarray(a, 'RGBA').convert('RGB').quantize(
colors=ncol, method=Image.MEDIANCUT, dither=Image.NONE)
c = usize_tail(L.rle_compress(pal4(q) + q.tobytes()), 1024 + stride * ht, usize)
if len(c) <= csize:
comp, keep, how = c, np.asarray(q.convert('RGB')), '兜底 %d 色' % ncol
break
assert comp is not None, '帧 %d 塞不进槽位' % n
new = blob[:12] + struct.pack('<III', method, csize, usize) + L.pad_rle_safe(comp, csize)
assert len(new) == len(blob)
_, back = L.decode_lvgl_bin(new)
raw_len = len(L.rle_decompress(new[24:24 + csize], 1, None))
want = np.asarray(keep, dtype=np.uint8).reshape(-1, 3)
got = np.asarray(back.convert('RGB'), dtype=np.uint8).reshape(-1, 3)
same = bool((got == want).all()) # 与"实际写进去的图"逐像素一致
decode_ck += 1 if (same and raw_len == usize) else 0
# 对 q63 原帧的误差:亮区(圆环)必须为 0,暗区允许有损
d = np.abs(keep.astype(np.int16) - s_rgb.astype(np.int16))
br = lum > 60
e_br = float(d[br].mean()) if br.any() else 0.0
e_dk = float(d[~br].mean()) if (~br).any() else 0.0
used[how] = used.get(how, 0) + 1
print(' 帧 %-3d <- q63 第 %-3d 帧 槽位 %6d B 编码 %6d B 亮区均差 %.2f 暗区均差 %.2f %s'
% (n, m, len(blob), len(comp), e_br, e_dk, how))
records.append(dict(name='charge%d' % n, src='charge%d' % m, offset=ino.data_off,
length=len(blob), data=new, how=how))
print('回解自检通过 %d / %d(兜底 %d 帧)' % (decode_ck, len(records), fallback))
print('编码方式分布:%s' % used)
shutil.copyfile(STOCK, PATCHED)
with open(PATCHED, 'r+b') as f:
for r in records:
f.seek(r['offset'])
f.write(r['data'])
json.dump(dict(device='Xiaomi Watch S4 eSIM (o62lte) vela_system.bin', target_dir=DIRP,
source='Xiaomi Watch S5 41mm (q63) charging frames',
sample='all' if SAMPLE is None else SAMPLE,
image_sha256=hashlib.sha256(stock.data).hexdigest(),
records=[dict(name=r['name'], offset=r['offset'], length=r['length'],
src=r['src'], how=r['how']) for r in records]),
open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
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' % (stock.sb_size == r2.sb_size and stock.sb_cksum == r2.sb_cksum))
sh = open(PATCHED, 'rb').read()
print('回放(改后镜像 == 计划字节):%s' % all(
sh[r['offset']:r['offset'] + r['length']] == r['data'] for r in records))
print('负载合计 %.2f MB;写出 %s' % (sum(r['length'] for r in records) / 1048576,
os.path.join(OUTDIR, 'manifest.json')))
def pal_lum(pal, vals):
return pal[vals][:, :3].max(axis=1) # 只取最大值,通道序无关
def rgb_of(pal, idx):
return pal[idx][:, :, [2, 1, 0]]
if __name__ == '__main__':
main()