204 lines
9.3 KiB
Python
204 lines
9.3 KiB
Python
# -*- 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()
|