Files
o62lte-customising-plan/s5e-icons/tools/lvgl_bin.py
T
2026-10-02 21:34:56 +08:00

151 lines
5.2 KiB
Python

"""LVGL v9 RLE codec (LV_IMAGE_COMPRESS_RLE).
Control byte grammar (upstream `lv_rle_decompress`):
high bit CLEAR (0x00..0x7F) : repeat the next single `blk`-byte block `ctrl` times
high bit SET (0x80|count) : copy `count` blocks verbatim (literal run)
count is 1..=127
For palette formats (I8) the stream decodes to 256*4 palette bytes followed by
w*h palette indices.
"""
import struct
from PIL import Image
MAGIC = 0x19
FLAG_COMPRESSED = 0x08
METHOD_NONE, METHOD_RLE, METHOD_LZ4 = 0, 1, 2
def rle_decompress(data, blk, out_len=None):
out = bytearray()
i, n = 0, len(data)
while i < n:
ctrl = data[i]; i += 1
if ctrl & 0x80:
cnt = ctrl & 0x7F
nb = cnt * blk
if i + nb > n:
raise ValueError('truncated literal run')
out += data[i:i + nb]
i += nb
else:
cnt = ctrl
if i + blk > n:
raise ValueError('truncated repeat unit')
unit = data[i:i + blk]
i += blk
out += unit * cnt
if out_len is not None and len(out) >= out_len:
break
return bytes(out)
def rle_compress(raw, max_run=127):
"""LVGL RLE 编码器(rle_decompress 的逆向,blk=1)。
贪心:先找同字节游程(>=2 用「重复」指令),否则攒「原样」指令。
输出长度总是 <= 原图像素流,因为最坏情况每条 literal 只多 1 个控制字节。
"""
out = bytearray()
i, n = 0, len(raw)
while i < n:
v = raw[i]
j = i + 1
while j < n and raw[j] == v and j - i < max_run:
j += 1
run = j - i
if run >= 2:
out.append(run); out.append(v); i = j
continue
lit = bytearray()
k = i
while k < n and len(lit) < max_run:
if k + 1 < n and raw[k] == raw[k + 1]:
break
lit.append(raw[k]); k += 1
if not lit: # 理论上到不了这里
lit.append(v); k = i + 1
out.append(0x80 | len(lit)); out += lit
i = k
return bytes(out)
def pad_rle(comp, want):
"""把 RLE 流补到 want 字节:用 ctrl=0(重复 0 次 = 产出 0 像素)的 2 字节空指令。
ctrl=0 是合法指令(`len = 0`,两个越界判断都不触发),所以设备端解码器读完 csize
字节既不会越界、也不会返回 INVALID —— 这是「等长覆盖」能成立的关键。
差值为奇数时先把一条 literal 拆成两条(payload 不变、多 1 个控制字节)。
"""
d = want - len(comp)
if d < 0:
return None
if d % 2:
b = bytearray(comp)
for idx in range(len(b) - 1, -1, -1):
if b[idx] & 0x80 and (b[idx] & 0x7F) >= 2:
cnt = b[idx] & 0x7F
lit = bytes(b[idx + 1: idx + 1 + cnt])
half = cnt // 2
b[idx: idx + 1 + cnt] = (bytes([0x80 | half]) + lit[:half] +
bytes([0x80 | (cnt - half)]) + lit[half:])
break
else:
return None
comp = bytes(b)
d -= 1
return comp + b'\x00\x00' * (d // 2)
def parse_header(blob):
if len(blob) < 12 or blob[0] != MAGIC:
return None
magic, cf, flags, w, h, stride, rsv = struct.unpack('<BBHHHHH', blob[:12])
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=rsv)
def decode_lvgl_bin(blob):
"""Return (header, RGBA PIL image) for uncompressed / RLE LVGL v9 .bin.
NOTE stride: LVGL pads each row to the header's `stride`, which is NOT always
equal to w (e.g. the S5 41mm control-center icons are 116 wide with stride 128).
Reading them as w*h contiguous pixels yields rainbow garbage, so the row pitch
below is taken from the header.
"""
h = parse_header(blob)
if h is None:
return None, None
cf, w, ht = h['cf'], h['w'], h['h']
body = blob[12:]
if h['flags'] & FLAG_COMPRESSED:
method, csize, usize = struct.unpack('<III', body[:12])
if method != METHOD_RLE:
raise ValueError(f'unsupported compression method {method}')
blk = 4 if cf == 0x10 else 1 # ARGB8888 是 4 字节一块,I8 是 1 字节
raw = rle_decompress(body[12:12 + csize], blk, usize)
else:
raw = body
img = Image.new('RGBA', (w, ht))
px = img.load()
if cf == 0x10: # ARGB8888 (BGRA)
pitch = max(h['stride'] or 0, w * 4)
if len(raw) < pitch * ht:
return h, None
for y in range(ht):
row = y * pitch
for x in range(w):
b, g, r, a = raw[row + x * 4:row + x * 4 + 4]
px[x, y] = (r, g, b, a)
elif cf == 0x0a: # I8: palette then indices
pitch = max(h['stride'] or 0, w)
if len(raw) < 1024 + pitch * ht:
return h, None
pal = raw[:1024]
palette = [(pal[i*4+2], pal[i*4+1], pal[i*4+0], pal[i*4+3]) for i in range(256)]
for y in range(ht):
row = 1024 + y * pitch
for x in range(w):
px[x, y] = palette[raw[row + x]]
else:
return h, None
return h, img