MicroPython JPEG
April 5, 2026 · View on GitHub
A fast and memory-efficient JPEG decoder/encoder module for MicroPython (ESP port).
Features
- JPEG Decoder: normal decode (full frame) and block decode (tile/strip decode)
- JPEG Encoder
decode_into()supports zero-copy writing into a user-provided framebuffer- Designed for embedded UI/animation workloads (cooperative scheduling)
Getting started
import jpeg
print("JPEG Driver Version:", jpeg.version())
Decoder
Create a decoder
decoder = jpeg.Decoder(
pixel_format="RGB565_LE",
rotation=0,
block=False,
scale_width=0, scale_height=0,
clipper_width=0, clipper_height=0,
return_bytes=False,
)
Parameters
pixel_format: Output pixel format.- Supported:
RGB565_BE,RGB565_LE,CbYCrY,RGB888
- Supported:
rotation:0,90,180,270block:False(default): normal decode (full image per decode)True: block decode mode (each decode produces one block; usually 8 or 16 lines per block)- If
block=True: scaling/clipper/rotation are not supported (library limitation)
scale_width,scale_height:- Optional output scaling (must match JPEG constraints and be multiple of 8)
clipper_width,clipper_height:- Optional crop (must be multiple of 8; must be <= scale)
return_bytes:False(default):decode()returnsmemoryviewTrue:decode()returnsbytes
get_img_info(jpeg_data)
info = decoder.get_img_info(jpeg_data)
Returns:
- normal mode:
[width, height] - block mode:
[width, height, blocks, block_height]
Where:
blocks= number of blocks to decode full imageblock_heightis usually8or16
decode(jpeg_data)
img_or_block = decoder.decode(jpeg_data)
- If
block=False: returns the full decoded frame - If
block=True: returns the next decoded block (full width, height = 8 or 16 lines) - When block decoding is finished:
- returns
None
- returns
This API is useful when you want the raw block bytes/memoryview and handle placement/output yourself.
decode_into(jpeg_data, out_buffer, *, blocks=0) ✅ NEW API
done = decoder.decode_into(jpeg_data, framebuffer) # blocks defaults to 0 (FULL)
done = decoder.decode_into(jpeg_data, framebuffer, blocks=1) # step
Goal
Decode and write directly into a user-provided buffer (framebuffer).
This avoids Python slice copies and reduces GC pressure.
Return value
- Returns bool
True: this call completed one full decode round (framebuffer is ready)False: not finished yet (only possible inblock=Truewithblocks>0)
blocks parameter (important)
blocks=0(default): FULL mode- Continue decoding from the current progress until the frame is complete
- Returns
True
blocks>0: STEP mode- Decode at most
blocksblocks from current progress - Returns:
Falseif not finished yetTrueif finished within this call
- Decode at most
blocks<0: raisesValueError
Auto-rewind behavior (by design)
After a full round is completed, if you call decode_into() again with the same jpeg_data,
the decoder will automatically restart (rewind) and decode again.
This simplifies animation loops (you control whether to call again or switch to the next frame at a higher level).
Buffer requirements
- If
block=False:out_buffermust be at least the full decoded frame size
- If
block=True:out_buffermust be large enough to hold the full frame (because the module writes each block into the correct offset automatically)
Practical guide
1) Choose pixel format and size the framebuffer
RGB565_BE/RGB565_LE/CbYCrY: 2 bytes per pixelRGB888: 3 bytes per pixel
import jpeg
img = open("image.jpg", "rb").read()
dec = jpeg.Decoder(pixel_format="RGB565_LE", rotation=0, block=True)
info = dec.get_img_info(img)
w, h = info[0], info[1]
bytes_per_pixel = 2
fb = bytearray(w * h * bytes_per_pixel)
2) Full decode (simple, highest throughput per call)
Use this when you can afford decoding the whole frame in one call.
done = dec.decode_into(img, fb) # blocks defaults to 0
if done:
pass
3) STEP decode (cooperative scheduling, UI-friendly)
Use this when you want to spread decoding across multiple iterations to keep the UI responsive.
done = dec.decode_into(img, fb, blocks=1)
if done:
pass
4) Correct loop pattern for STEP mode
decode_into(..., blocks>0) can return False until the image is complete.
while True:
done = dec.decode_into(img, fb, blocks=1)
if done:
break
# do other work here (render UI, poll input, etc.)
When to use decode_into vs decode
- Prefer
decode_intowhen your goal is a full framebuffer for display (avoids Python slice assembly and reduces GC pressure). - Use
decode(block=True)when you need raw block bytes and want to handle placement/output yourself.
Notes and caveats
- STEP mode is meaningful when using
block=Trueandblocks>0. block=Truehas limitations: rotation must be 0, and scaling/clipping are not supported.- The decoder auto-rewinds after completing a full round when you call
decode_into()again with the samejpeg_data, which is convenient for animation loops.
Encoder
Create an encoder
enc = jpeg.Encoder(
height=240,
width=320,
pixel_format="RGB888",
quality=90,
rotation=0,
)
Parameters
height,width: requiredpixel_format(input format): supported by driver (e.g.RGB888,RGB565,RGBA,YCbYCr,CbYCrY,GRAY, etc.)quality: 1..100rotation:0,90,180,270
encode(img_data)
jpeg_bytes = enc.encode(raw_image_bytes)
Returns bytes.
Benchmark
Benchmark script
Use the provided benchmark.py (updated for the new bool-return decode_into API).
Test image
- Resolution: 240×240
- JPEG size: 32627 bytes
- Blocks: 30 (block height: 8)
- NR = 100
Decoder results
| Format | FPS normal decode (decode, block=False) | FPS block decode (decode, block=True) | FPS block decode + write (python slice) | FPS decode_into step (blocks=1) | FPS decode_into full (blocks=0) |
|---|---|---|---|---|---|
| RGB565_BE | 18.47 | 24.65 | 4.82 | 21.38 | 21.52 |
| RGB565_LE | 18.46 | 24.65 | 4.82 | 21.40 | 21.52 |
| RGB888 | 16.49 | 23.75 | 3.43 | 20.19 | 20.34 |
| CbYCrY | 18.92 | 26.03 | 4.85 | 21.99 | 22.13 |
Notes
block decodeis fastest when you only need block output (no full-frame assembly).python sliceassembly is slow due to Python-level copying.decode_intoprovides a practical middle ground: good speed + direct framebuffer output.
Encoder results
| Quality | FPS (RGB888) |
|---|---|
| 100 | 10.95 |
| 90 | 16.88 |
| 80 | 19.18 |
| 70 | 20.23 |
| 60 | 22.46 |
Build (ESP-IDF / MicroPython external C module)
Requirements
- ESP-IDF: tested on 5.2 / 5.3 / 5.4
- MicroPython: tested around v1.24
- ESP JPEG library:
espressif/esp_new_jpeg
Add dependency in idf_component.yml (example):
dependencies:
espressif/esp_new_jpeg: "^1.0.0"
Build
. <path-to-esp-idf>/export.sh
cd micropython/ports/esp32
make USER_C_MODULES=../../../../mp_jpeg/micropython.cmake BOARD=<Your-Board> clean
make USER_C_MODULES=../../../../mp_jpeg/micropython.cmake BOARD=<Your-Board> submodules
make USER_C_MODULES=../../../../mp_jpeg/micropython.cmake BOARD=<Your-Board> all
Example usage
Full decode into framebuffer (fast, simple)
import jpeg
img = open("image.jpg","rb").read()
dec = jpeg.Decoder(pixel_format="RGB565_LE", rotation=0, block=True)
info = dec.get_img_info(img)
w, h = info[0], info[1]
fb = bytearray(w * h * 2)
done = dec.decode_into(img, fb) # default blocks=0 FULL
# done == True
# fb is ready
Cooperative decode (UI-friendly)
# each frame decode 1 block to avoid blocking UI
done = dec.decode_into(img, fb, blocks=1)
if done:
# completed this image
pass