perf(esp32-cam-lcd-preview): 4x faster preview by shrinking SPI traffic

User reported the live preview "looks slow" after the JPEG decode fix.
Diagnosis: each tft.drawRGBBitmap pushes width × height × 2 bytes over
SPI, and every byte takes a full QEMU → worker → backend (WS) → frontend
round-trip. At 160×120 that's 38 400 messages per frame; the bus
saturates at ~0.2 fps perceived.

Two changes shrink the per-frame SPI bandwidth:

1. Preview 160×120 → 80×60 (and JPG_SCALE_2X → JPG_SCALE_4X).
   38 400 bytes/frame → 9 600 bytes/frame. Already 4× faster.

2. Status bar redraw throttled to every 10th frame instead of every
   frame. The text writes (printf, fillRect, fillCircle) account for
   another ~1-2 KB of SPI traffic per loop iteration. Skipping 9 of
   every 10 redraws frees up a chunk more bandwidth without losing
   the headline numbers (fps, frame counter) — they just refresh
   once a second instead of 5x/sec.

Also dropped the trailing `delay(20)` — we don't need an artificial
throttle, the SPI bus is the throttle.

Real-hardware effect: zero. ESP32 SPI runs at 80 MHz; a full
160×120 bitmap pushes in ~4 ms either way.

Applied in two places:
- examples/esp32-cam-lcd-preview/esp32-cam-lcd-preview.ino
- frontend/src/data/examples.ts (in-app gallery copy)

Long-term plan: batch SPI bytes at the worker level (one WS message
per N bytes instead of per byte) — that's a deeper change in
qemu-lcgamboa + Esp32Bridge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-03 00:15:06 -03:00
parent c4c446cf39
commit a20b10a252
2 changed files with 42 additions and 25 deletions

View File

@ -72,15 +72,26 @@
SPIClass tftSPI(VSPI);
Adafruit_ILI9341 tft = Adafruit_ILI9341(&tftSPI, TFT_DC, TFT_CS, TFT_RST);
// Preview is scaled 1/2 from the QVGA capture (320×240 → 160×120).
// 160 × 120 × 2 bytes = 38 400 bytes — fits comfortably in DRAM.
// Centered in the 320×240 landscape TFT at offset (80, 60).
#define PREVIEW_W 160
#define PREVIEW_H 120
#define PREVIEW_X 80
#define PREVIEW_Y 60
// Preview is scaled 1/4 from the QVGA capture (320×240 → 80×60).
// 80 × 60 × 2 bytes = 9 600 bytes — minimal SPI traffic per frame.
// Centered in the 320×240 landscape TFT.
//
// Why so small: every SPI byte takes a full QEMU→worker→backend→
// frontend round-trip. drawRGBBitmap pushes width×height×2 bytes;
// at 80×60 that's 9 600 SPI events per frame vs 38 400 at 160×120.
// Net effect on the user: roughly 4× faster perceived FPS in the
// emulator. Real hardware doesn't care — its SPI runs at 80 MHz.
#define PREVIEW_W 80
#define PREVIEW_H 60
#define PREVIEW_X 120 // (320 - 80) / 2
#define PREVIEW_Y 90 // (240 - 60) / 2
static uint8_t rgbBuf[PREVIEW_W * PREVIEW_H * 2];
// Throttle the status-bar redraw — text writes hit SPI too. Updating
// the bar once every STATUS_REFRESH_EVERY frames keeps the headline
// numbers visible without burning bandwidth on every loop iteration.
#define STATUS_REFRESH_EVERY 10
// Counters for status overlay
uint32_t frame_count = 0;
uint32_t decode_fails = 0;
@ -223,19 +234,15 @@ void loop() {
frame_count++;
size_t fb_len = fb->len;
// Decode the JPEG into RGB565 at half resolution (160×120).
// The conversions library ships inside libesp32-camera.a; the
// Velxio CMakeLists template adds the include path automatically.
bool ok = jpg2rgb565(fb->buf, fb->len, rgbBuf, JPG_SCALE_2X);
// Decode the JPEG into RGB565 at 1/4 resolution (80×60).
bool ok = jpg2rgb565(fb->buf, fb->len, rgbBuf, JPG_SCALE_4X);
esp_camera_fb_return(fb);
if (ok) {
// Push the bitmap to the centre of the TFT.
tft.drawRGBBitmap(PREVIEW_X, PREVIEW_Y,
(uint16_t*)rgbBuf, PREVIEW_W, PREVIEW_H);
} else {
decode_fails++;
// Decode failure → grey rectangle with an X.
tft.fillRect(PREVIEW_X, PREVIEW_Y, PREVIEW_W, PREVIEW_H, ILI9341_DARKGREY);
tft.drawLine(PREVIEW_X, PREVIEW_Y,
PREVIEW_X + PREVIEW_W, PREVIEW_Y + PREVIEW_H, ILI9341_RED);
@ -243,9 +250,10 @@ void loop() {
PREVIEW_X, PREVIEW_Y + PREVIEW_H, ILI9341_RED);
}
// Always refresh the status bar (cheap — only top 60 px).
draw_status_bar((uint32_t)fb_len, ok);
// Mild throttle so the CPU has time to refresh the SPI bus.
delay(20);
// Throttled status-bar redraw — every Nth frame only. Text writes hit
// the SPI bus too; updating once every STATUS_REFRESH_EVERY frames
// keeps the headline visible without burning bandwidth on every loop.
if (frame_count % STATUS_REFRESH_EVERY == 0) {
draw_status_bar((uint32_t)fb_len, ok);
}
}

View File

@ -7029,12 +7029,19 @@ void loop() {
SPIClass tftSPI(VSPI);
Adafruit_ILI9341 tft = Adafruit_ILI9341(&tftSPI, TFT_DC, TFT_CS, TFT_RST);
#define PREVIEW_W 160
#define PREVIEW_H 120
#define PREVIEW_X 80
#define PREVIEW_Y 60
// 1/4 scale (80×60) — minimises SPI traffic in the emulator.
// Each SPI byte goes through QEMU→worker→backend→frontend, so
// drawRGBBitmap dominates the loop. 80×60 = 9600 bytes/frame vs
// 160×120 = 38400 — roughly 4× faster perceived FPS.
#define PREVIEW_W 80
#define PREVIEW_H 60
#define PREVIEW_X 120
#define PREVIEW_Y 90
static uint8_t rgbBuf[PREVIEW_W * PREVIEW_H * 2];
// Throttle status-bar redraw — text writes hit SPI too.
#define STATUS_REFRESH_EVERY 10
uint32_t frame_count = 0, decode_fails = 0, null_fb = 0;
unsigned long start_ms = 0;
@ -7126,7 +7133,7 @@ void loop() {
}
frame_count++;
size_t fb_len = fb->len;
bool ok = jpg2rgb565(fb->buf, fb->len, rgbBuf, JPG_SCALE_2X);
bool ok = jpg2rgb565(fb->buf, fb->len, rgbBuf, JPG_SCALE_4X);
esp_camera_fb_return(fb);
if (ok) {
tft.drawRGBBitmap(PREVIEW_X, PREVIEW_Y,
@ -7140,8 +7147,10 @@ void loop() {
tft.drawLine(PREVIEW_X + PREVIEW_W, PREVIEW_Y,
PREVIEW_X, PREVIEW_Y + PREVIEW_H, ILI9341_RED);
}
draw_status_bar((uint32_t)fb_len, ok);
delay(20);
// Throttled status-bar redraw — text writes hit SPI too.
if (frame_count % STATUS_REFRESH_EVERY == 0) {
draw_status_bar((uint32_t)fb_len, ok);
}
}
`,
components: [