Skip to content

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

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

mikeysklar wants to merge 12 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
Comment thread ports/espressif/common-hal/jpegio/JpegDecoder.c Outdated
Comment thread ports/espressif/supervisor/port.c Outdated
Comment thread shared-module/displayio/Bitmap.c
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

mikeysklar commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Good point. Timing alone is not enough, since a decode can quietly fall back to software.

Added a read-only JpegDecoder.used_hardware property that says whether the last decode() used the JPEG engine. Other ports always report False. Tested on the S31 with hw_decode.py.

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
Reports whether the last decode() used the JPEG decoder peripheral, so
scripts and tests can check the path without timing it. Ports without
the hardware always report False.

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

Copy link
Copy Markdown
Collaborator Author

Two merge notes:

@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.

Don't wait to rebase on the video pipeline stuff because this is close. The allocation changes here are fine.

Comment thread ports/espressif/Makefile Outdated
Comment thread shared-bindings/jpegio/JpegDecoder.c Outdated
Comment thread shared-module/jpegio/JpegDecoder.h Outdated
Comment thread shared-module/jpegio/JpegDecoder.h Outdated
Comment thread shared-module/jpegio/JpegDecoder.h Outdated
mikeysklar and others added 6 commits October 20, 2026 12:25
Match the Makefile style of one section per target.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Removed per review. A software_ok option will cover the case of
expecting hardware decode and not getting it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This matches hw_deinit, which frees it from the finaliser. If the
engine cannot be created, hw_decoder stays NULL and decode() uses
TJpgDec.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
software_ok=True (the default) keeps falling back to TJpgDec when the
JPEG decoder peripheral cannot decode an image. software_ok=False raises
ValueError instead, including on chips without the peripheral, so code
that expects hardware decode finds out when it does not get it. The
error messages reuse existing translated strings.

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

Copy link
Copy Markdown
Collaborator Author

(me, not LLM):

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?

Sorry I misread this the first time.

As we settled on Discord:

JpegDecoder(software_ok=False) now raises ValueError when the hardware can't decode the image, including on chips without it.

The default stays True, so existing code is unchanged.

Not waiting on #11614 anymore.

Tested on three ESPs to verify changes worked:

Board Result
P4 720p in 9 to 18 ms. Unsupported cases raise with software_ok=False
S31 720p in 16 to 31 ms. Same cases raise
Metro ESP32-S3 Decodes in software by default. Raises with software_ok=False

Still waiting on adafruit/esp-idf #19 to point the submodule at it.

Match the esp-idf pin in adafruit#11643 (29d36039) so the two PRs merge cleanly
in either order. 29d36039 already contains the jpeg full-range change
(adafruit/esp-idf#18) this PR needs, plus #19 and #21.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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