Skip to content

Speed up JPEG decoding on ESP32-P4 and ESP32-S31 - #11634

Open
mikeysklar wants to merge 4 commits into
adafruit:mainfrom
mikeysklar:jpegio-hw-decode
Open

mikeysklar wants to merge 4 commits into
adafruit:mainfrom
mikeysklar:jpegio-hw-decode

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

What

jpegio now uses the chip's JPEG hardware on ESP32-P4 and ESP32-S31.

Why

Software decode is too slow for video on these chips.

Needs adafruit/esp-idf#18 (adds yuv_range) and #19 (S31 ROM fix).

The esp-idf submodule points at #19, which includes both.

Setting Effect
MICROPY_BYTES_PER_GC_BLOCK 64 on P4 and S31, so every GC allocation starts on a cache line. Same port_malloc change as #11614.
Cost Free heap at boot about 576 B lower.
CIRCUITPY_JPEGIO_HW On for P4 and S31, 0 elsewhere. Set to 0 to opt out.
1280x720, median of 10, hardware / TJpgDec 4:2:0 4:2:2 4:4:4
Adafruit P4 GPIO 9.2 / 302 ms 12.2 / 328 ms 18.2 / 385 ms
ESP32-S31-Korvo-1 16.0 / 375 ms 20.9 / 409 ms 31.0 / 477 ms

TJpgDec is timed from io.BytesIO; the hardware from bytes.

720p on P4 Hardware TJpgDec
Whole image at 0,0 into a same-size Bitmap (direct) 9.8 ms 312 ms
With an offset or clip (temporary buffer) 31 to 72 ms 142 to 403 ms
Used when Otherwise
open() got bytes, scale=0, 16-bit Bitmap, baseline color JPEG TJpgDec, as before
Bitmap is the image size, decoded at 0,0 Temporary buffer, freed before decode() returns
Skip index set, or JPEG not in DMA-reachable memory (e.g. flash) TJpgDec, as before
Memory P4 S31 Held
Flash +15.4 KB +15.6 KB Always
JPEG engine 684 B internal SRAM, 3.9 KB PSRAM 744 B internal SRAM, 4.0 KB PSRAM From the first hardware decode until the JpegDecoder is freed (finaliser)
Temporary buffer One padded frame in PSRAM Same During decode() only
Other chips None Flag defaults to 0
Tested on Adafruit P4 GPIO and ESP32-S31-Korvo-1, this branch rebased on main Result
tests/circuitpython-manual/jpegio/hw_decode.py Within 4 of 63 levels per channel of TJpgDec; grayscale, odd sizes and scale=1 fall back and match exactly; progressive raises as before
Offsets, clipping and skip indexes, 17 cases Exactly a full hardware decode blitted with the same arguments; skip indexes take TJpgDec
Create and drop decoders 10 times, and reload with a decoder held Engine freed each time; IDF heap back to the same level
Three runs plus reload GC heap free identical to a fresh boot. IDF heap 200 B lower after the first decode, then flat; likely IDF's interrupt allocator keeping a vector entry
Not tested Flash encryption

AI assistance: written with Claude Code. I ran the tests above on hardware.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to be explicit about whether the user wants hardware acceleration? Or do you think it is ok to do through speed testing?

I had a couple comments about structure.

Comment thread ports/espressif/mpconfigport.h Outdated

#if CIRCUITPY_JPEGIO_HW
// The JPEG engine decodes straight into Bitmaps whose pixels start on a cache line.
#define CIRCUITPY_DISPLAYIO_BITMAP_DATA_ALIGNMENT (64)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really want to add this. For camera I just have the VM and port_malloc always alloc aligned to 64 bytes. JPEGIO can check to just make sure.

This is how to do it:
https://github.com/adafruit/circuitpython/pull/11614/changes#diff-c9bb8741d3fb6b2364b58fa90638114b80d0dc512982dc3fccb21cc2087f20b8R233

Comment on lines +29 to +34
void espressif_jpegio_reset(void) {
if (decoder_engine != NULL) {
jpeg_del_decoder_engine(decoder_engine);
decoder_engine = NULL;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets not do a bulk reset here. Instead, change the allocation in shared-bindings to trigger a finaliser and have it deallocate.

Comment thread ports/espressif/supervisor/port.c Outdated
Comment on lines +491 to +494
#if CIRCUITPY_JPEGIO_HW
espressif_jpegio_reset();
#endif

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without the bulk reset then remove this.

self->stride = stride(width, bits_per_value);
self->data_alloc = false;
if (!data) {
data = m_malloc_without_collect(self->stride * height * sizeof(uint32_t));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't change this. Just align all allocations.

mikeysklar and others added 4 commits October 19, 2026 12:39
CIRCUITPY_JPEGIO_HW (default 0) means the port provides
jpegio_jpegdecoder_decode_hw(). decode() calls it first when open() was
given an in-memory buffer. If it returns false, TJpgDec decodes the image
as before. The espressif port turns it on for esp32p4 and esp32s31, the
targets with a JPEG decoder peripheral, and implements it with
esp_driver_jpeg. Other targets are unchanged.

CIRCUITPY_DISPLAYIO_BITMAP_DATA_ALIGNMENT (default 4) sets the alignment
of the pixel memory a Bitmap allocates. The two chips raise it to the
64 byte cache line, so a decode of the whole image at 0,0 into a Bitmap of
the same size goes straight into the Bitmap. The pixels stay in the GC
heap. Other decodes go through a temporary buffer that is freed before
decode() returns. Skip indexes, and input the DMA can't read, use TJpgDec.

The engine is created on the first decode and deleted on VM reset. The
color conversion is full range BT601, as JFIF specifies. This needs
yuv_range in jpeg_decode_cfg_t, added in adafruit/esp-idf.

1280x720, median of 10, hardware from bytes vs TJpgDec from io.BytesIO:
  ESP32-P4 (Adafruit P4 GPIO)   4:2:0 9.2 ms (302.1), 4:2:2 12.2 (327.5),
                                4:4:4 18.2 (385.2)
  ESP32-S31 (ESP32-S31-Korvo-1) 4:2:0 16.0 ms (374.8), 4:2:2 20.9 (409.4),
                                4:4:4 31.0 (477.4)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Points at adafruit/esp-idf#19, which also merges #18.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Use 64-byte GC blocks and align the PSRAM heap areas to them, as the
camera change does, so Bitmap pixels start on a cache line without a
Bitmap-specific alignment option. Revert the Bitmap allocation change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each JpegDecoder now owns its JPEG engine and releases it when it is
collected, instead of sharing one engine that is freed on VM reset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Thanks! Changes:

I'd keep hardware decode on by default. It costs under 1% of flash and RAM on P4 and S31, and 720p decodes in 9 to 16 ms instead of 300 to 375 ms. Builds can opt out with CIRCUITPY_JPEGIO_HW=0.

Re-ran the hardware tests after these changes, rebased on main. All pass on P4 and S31.

@mikeysklar
mikeysklar requested a review from tannewt October 19, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants