#10684·lvgl

LVGLImage.py: # RLE encoder can emit an invalid stream (control byte `0x80` = literal run of 0 blocks)

Author: pingyun001Created Sep 11, 2026Updated Sep 11, 2026

repro_minimal.py

LVGL version

v9.5

Platform

Host-side image converter (LVGLImage.py), Python 3.x, Windows 11. Reproduced both through the CLI (--ofmt BIN --cf ARGB8888) and by calling RLEImage.rle_compress() directly.

What happened?

The encoder sometimes emits the control byte 0x80 ("literal run of 0 blocks") and then still appends 128 blocks of payload, so the control byte and the payload disagree. The stream is not self-consistent: LVGL's own lv_rle_decompress() rejects it (returns 0), and some hardware RLE decoders get stuck on it.

Root cause — get_nonrepeat_count() clamps nonrepeat_count on the "threshold reached" path but not on the "end of data" path:

python
while True:
    value = data[index:index + blksize]
    if value == pre_value:
        repeat_cnt += 1
        if repeat_cnt > threshold:
            # repeat found.
            break
    else:
        pre_value = value
        nonrepeat_count += 1 + repeat_cnt
        repeat_cnt = 0
        if nonrepeat_count >= 127:      # <-- clamped only here
            nonrepeat_count = 127
            break

    index += blksize  # move to next position
    if index >= len(data):  # data end
        nonrepeat_count += repeat_cnt   # <-- can exceed 127 here
        break

return nonrepeat_count

On the end-of-data path the count can become 126 + repeat_cnt (up to ~142). The caller then does:

python
ctrl_byte = uint8_t(nonrepeat_cnt | 0x80)   # 128 | 0x80 -> 0x80 (truncated to 7 bits)
compressed_data.append(ctrl_byte)
compressed_data.append(memview[index:index + nonrepeat_cnt * blksize])   # still 128 blocks

i.e. control byte says 0 blocks, payload carries 128 blocks.

How to reproduce?

How to reproduce

Minimal case (512 bytes of input), attached as repro_minimal.py:

python
import sys; sys.path.insert(0, '<lvgl>/scripts')
from LVGLImage import RLEImage, ColorFormat

BLK, TAIL = 4, bytes([0x11, 0x22, 0x33, 0xFF])
data = bytearray()
for i in range(125):                       # 125 pairwise different pixels
    data += bytes([i, (i * 3) & 0xFF, (i * 5) & 0xFF, 0xFF])
data += TAIL * 3                           # 3 identical pixels at the very end
img = RLEImage(cf=ColorFormat.ARGB8888, w=128, h=1, data=bytes(data))
print(img.rle_compress(data, BLK)[:8].hex(" "))

Observed:

80 00 00 00 ff 01 03 05      <- first control byte 0x80 = literal run of 0 blocks
stream size: 513 B (1 control byte + 512 B payload)
decoder walk: INPUT OVERRUN: ctrl 0xFF at 16 claims 508 byte(s), 496 left

Expected: ff (literal run of 127 blocks) followed by one more control byte for the remaining block.

Real-world sample: img_282.png (234x173 ARGB8888, converted with the official tool) contains the same defect at stream offset 161059 (80 00 00 00 ff ...); lv_rle_decompress() returns 0 for it. The converted .bin is attached as img_282.bin.

Suggested fix

Clamp on every exit path, e.g.:

diff
-        return nonrepeat_count
+        return min(nonrepeat_count, 127)

The bitstream format is unchanged and previously valid streams stay byte-identical.

Attached files

  • LVGLImage_fixed.py — patched copy of scripts/LVGLImage.py (v9.5) containing only the single-hunk fix above; it can also be used as a drop-in replacement for testing. Verified before posting: converting img_282.png below with it produces a stream that LVGL's own lv_rle_decompress() decodes completely (161928/161928 B, previously 0) and that matches the source PNG pixel for pixel (0 differing pixels).
  • repro_minimal.py — self-contained minimal reproducer, prints BUG REPRODUCED with the unpatched script and no anomaly in this run with the patched one. Pass the LVGL scripts folder through the LVGL_SCRIPTS environment variable.
  • img_282.png — real-world sample whose conversion triggers the defect (0x80 at stream offset 161059); enough to regenerate the bad .bin with either script.

LVGLImage_fixed.py

repro_minimal.py

Image