feat: replace JPEGDEC with stb_image for robust JPEG decoding

JPEGDEC (error 3: UNSUPPORTED_FEATURE) and M5GFX's TJpgDec both failed
on ~70% of Immich preview images due to:
- Optimized Huffman tables exceeding 12-bit DC code limit
- Extended sequential mode (SOF1) misidentified as baseline
- Progressive JPEG not handled by either decoder

stb_image.h handles all standard JPEG modes (baseline, progressive,
extended sequential, arbitrary Huffman tables) in a single decoder.

Changes:
- Add vendored stb_image.h (JPEG-only, PSRAM allocator)
- Rewrite decodeAndFit() to use stbi_load_from_memory()
- Remove JPEGDEC library dependency
- Remove jpegDrawCallback, DecodeContext, parseJpegHeader,
  decodeFallbackLGFX (all obsolete)
- Flash usage decreased: 1345KB -> 1317KB

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 20:22:06 -04:00
parent 7805c5359e
commit 7f51b274c4
5 changed files with 8018 additions and 256 deletions

View File

@@ -24,7 +24,6 @@ lib_deps =
mathieucarbou/ESP Async WebServer @ ^3.0.6
bblanchon/ArduinoJson @ ^7.0.0
ricmoo/QRCode @ ^0.0.1
bitbank2/JPEGDEC @ ^1.8.4
lib_ignore =
WebServer

View File

@@ -1,5 +1,6 @@
#include "image_pipeline.h"
#include "blue_noise.h"
#include "stb_image.h"
#include <M5GFX.h>
#include <esp_heap_caps.h>
#include <cstring>
@@ -311,33 +312,6 @@ static void compressDynamicRange(uint8_t* rgb, size_t pixelCount) {
sourceBlackY, sourceWhiteY, blackY, whiteY);
}
// JPEGDEC draw callback: receives decoded MCU blocks and writes RGB888 to buffer
static int jpegDrawCallback(JPEGDRAW* pDraw) {
auto* ctx = static_cast<DecodeContext*>(pDraw->pUser);
if (ctx == nullptr || ctx->rgbBuffer == nullptr) return 0;
for (int y = 0; y < pDraw->iHeight; y++) {
int dstRow = pDraw->y + y;
if (dstRow < 0 || dstRow >= ctx->bufferHeight) continue;
for (int x = 0; x < pDraw->iWidthUsed; x++) {
int dstCol = pDraw->x + x;
if (dstCol < 0 || dstCol >= ctx->bufferWidth) continue;
// Convert RGB565 to RGB888
uint16_t pixel = pDraw->pPixels[y * pDraw->iWidth + x];
uint8_t r = (pixel >> 11) << 3;
uint8_t g = ((pixel >> 5) & 0x3F) << 2;
uint8_t b = (pixel & 0x1F) << 3;
size_t dstIdx = ((size_t)dstRow * ctx->bufferWidth + dstCol) * 3;
ctx->rgbBuffer[dstIdx] = r;
ctx->rgbBuffer[dstIdx + 1] = g;
ctx->rgbBuffer[dstIdx + 2] = b;
}
}
return 1;
}
// Average a single edge of the fitted image (4 rows or columns deep)
static constexpr int EDGE_DEPTH = 4;
@@ -660,248 +634,42 @@ static void bilinearResize(const uint8_t* src, uint16_t srcW, uint16_t srcH,
}
}
// Parse JPEG markers properly to find SOF and extract dimensions.
static bool parseJpegHeader(const uint8_t* data, size_t size,
uint16_t* width, uint16_t* height, bool* isProgressive) {
*width = 0;
*height = 0;
*isProgressive = false;
if (size < 2 || data[0] != 0xFF || data[1] != 0xD8) return false;
size_t pos = 2;
while (pos + 4 < size) {
if (data[pos] != 0xFF) { pos++; continue; }
while (pos < size && data[pos] == 0xFF) pos++;
if (pos >= size) break;
uint8_t marker = data[pos++];
if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xCC) {
if (pos + 7 > size) break;
*height = (data[pos + 3] << 8) | data[pos + 4];
*width = (data[pos + 5] << 8) | data[pos + 6];
*isProgressive = (marker == 0xC2);
return true;
}
if (marker == 0xDA) break;
if (pos + 1 >= size) break;
uint16_t segLen = (data[pos] << 8) | data[pos + 1];
pos += segLen;
}
return false;
}
// Fallback decoder using LGFX's built-in TJPGD
static uint8_t* decodeFallbackLGFX(uint8_t* data, size_t size,
uint16_t targetW, uint16_t targetH,
uint16_t imgW, uint16_t imgH,
uint16_t* outW, uint16_t* outH) {
// Calculate decode dimensions (cap at 960px longest side)
static constexpr uint16_t MAX_DECODE = 960;
uint16_t decodeW = imgW;
uint16_t decodeH = imgH;
uint16_t maxSide = max(imgW, imgH);
if (maxSide > MAX_DECODE) {
float scale = (float)MAX_DECODE / (float)maxSide;
decodeW = (uint16_t)(imgW * scale);
decodeH = (uint16_t)(imgH * scale);
}
Serial.printf("[pipeline/lgfx] Decoding %dx%d -> %dx%d sprite\n",
imgW, imgH, decodeW, decodeH);
lgfx::LGFX_Sprite sprite;
sprite.setPsram(true);
sprite.setColorDepth(lgfx::color_depth_t::rgb888_3Byte);
if (!sprite.createSprite(decodeW, decodeH)) {
Serial.println("[pipeline/lgfx] Sprite alloc failed");
return nullptr;
}
sprite.fillSprite(0);
float scaleX = (float)decodeW / (float)imgW;
float scaleY = (float)decodeH / (float)imgH;
if (!sprite.drawJpg(data, size, 0, 0, decodeW, decodeH, 0, 0, scaleX, scaleY)) {
Serial.println("[pipeline/lgfx] drawJpg failed (possibly progressive)");
sprite.deleteSprite();
return nullptr;
}
// Fit-contain: scale to fit entirely within target
float fitScaleW = (float)targetW / (float)decodeW;
float fitScaleH = (float)targetH / (float)decodeH;
float fitScale = fminf(fitScaleW, fitScaleH);
uint16_t fitW = (uint16_t)(decodeW * fitScale);
uint16_t fitH = (uint16_t)(decodeH * fitScale);
// Extract full decoded image from sprite
size_t decodeBufSize = (size_t)decodeW * decodeH * 3;
uint8_t* decoded = (uint8_t*)ps_malloc(decodeBufSize);
if (decoded == nullptr) {
sprite.deleteSprite();
return nullptr;
}
for (uint16_t y = 0; y < decodeH; y++) {
for (uint16_t x = 0; x < decodeW; x++) {
lgfx::bgr888_t color = sprite.readPixelRGB(x, y);
size_t idx = ((size_t)y * decodeW + x) * 3;
decoded[idx] = color.r;
decoded[idx + 1] = color.g;
decoded[idx + 2] = color.b;
}
}
sprite.deleteSprite();
// Resize to fit dimensions
uint8_t* fitted = (uint8_t*)ps_malloc((size_t)fitW * fitH * 3);
if (fitted == nullptr) {
free(decoded);
return nullptr;
}
bilinearResize(decoded, decodeW, decodeH, fitted, fitW, fitH);
free(decoded);
// Allocate final target buffer (zeroed)
size_t finalSize = (size_t)targetW * targetH * 3;
uint8_t* final_buf = (uint8_t*)ps_calloc(targetW * targetH, 3);
if (final_buf == nullptr) {
free(fitted);
return nullptr;
}
// Center the fitted image in the final buffer
uint16_t offsetX = (targetW - fitW) / 2;
uint16_t offsetY = (targetH - fitH) / 2;
for (uint16_t y = 0; y < fitH; y++) {
memcpy(final_buf + ((size_t)(offsetY + y) * targetW + offsetX) * 3,
fitted + (size_t)y * fitW * 3,
fitW * 3);
}
// Fill letterbox bars with per-edge average colors
fillLetterbox(final_buf, targetW, targetH, fitted, fitW, fitH, offsetX, offsetY);
free(fitted);
Serial.printf("[pipeline/lgfx] Fit %dx%d into %dx%d (offset %d,%d)\n",
fitW, fitH, targetW, targetH, offsetX, offsetY);
*outW = targetW;
*outH = targetH;
return final_buf;
}
uint8_t* ImagePipeline::decodeAndFit(uint8_t* data, size_t size,
uint16_t targetW, uint16_t targetH,
uint16_t* outW, uint16_t* outH) {
// Parse JPEG header to get dimensions and detect progressive
uint16_t headerW = 0, headerH = 0;
bool progressive = false;
if (parseJpegHeader(data, size, &headerW, &headerH, &progressive)) {
Serial.printf("[pipeline] Header: %dx%d %s\n", headerW, headerH,
progressive ? "progressive" : "baseline");
} else {
Serial.println("[pipeline] Could not parse JPEG header");
}
if (progressive) {
Serial.println("[pipeline] Progressive JPEG — skipping (server still reprocessing?)");
// Decode JPEG using stb_image (handles baseline, progressive, extended sequential)
int imgW, imgH, channels;
uint8_t* decoded = stbi_load_from_memory(data, (int)size, &imgW, &imgH, &channels, 3);
if (decoded == nullptr) {
Serial.printf("[pipeline] stbi decode failed: %s\n", stbi_failure_reason());
return nullptr;
}
// Primary path: JPEGDEC
JPEGDEC jpeg;
if (!jpeg.openRAM(data, (int)size, jpegDrawCallback)) {
int err = jpeg.getLastError();
Serial.printf("[pipeline] JPEGDEC openRAM failed (err %d), trying LGFX fallback\n", err);
if (headerW > 0 && headerH > 0) {
return decodeFallbackLGFX(data, size, targetW, targetH,
headerW, headerH, outW, outH);
}
return nullptr;
}
int imgW = jpeg.getWidth();
int imgH = jpeg.getHeight();
Serial.printf("[pipeline] JPEG: %dx%d, type=baseline\n", imgW, imgH);
// Determine decode scale (reduce large images before fit)
int decodeOptions = 0;
int decodeW = imgW;
int decodeH = imgH;
if (imgW > targetW * 4 && imgH > targetH * 4) {
decodeOptions = JPEG_SCALE_QUARTER;
decodeW = imgW / 4;
decodeH = imgH / 4;
Serial.printf("[pipeline] Using 1/4 scale: %dx%d\n", decodeW, decodeH);
} else if (imgW > targetW * 2 && imgH > targetH * 2) {
decodeOptions = JPEG_SCALE_HALF;
decodeW = imgW / 2;
decodeH = imgH / 2;
Serial.printf("[pipeline] Using 1/2 scale: %dx%d\n", decodeW, decodeH);
}
// Decode full image (no cropping — we want the whole photo)
size_t decodeBufSize = (size_t)decodeW * decodeH * 3;
uint8_t* decodedRgb = (uint8_t*)ps_malloc(decodeBufSize);
if (decodedRgb == nullptr) {
Serial.printf("[pipeline] Failed to allocate %u KB for decode buffer\n",
(unsigned)(decodeBufSize / 1024));
jpeg.close();
return nullptr;
}
memset(decodedRgb, 0, decodeBufSize);
DecodeContext ctx;
ctx.rgbBuffer = decodedRgb;
ctx.bufferWidth = (uint16_t)decodeW;
ctx.bufferHeight = (uint16_t)decodeH;
jpeg.setUserPointer(&ctx);
jpeg.setPixelType(RGB565_LITTLE_ENDIAN);
if (!jpeg.decode(0, 0, decodeOptions)) {
Serial.printf("[pipeline] JPEGDEC decode failed (err %d), trying LGFX fallback\n",
jpeg.getLastError());
free(decodedRgb);
jpeg.close();
if (headerW > 0 && headerH > 0) {
return decodeFallbackLGFX(data, size, targetW, targetH,
headerW, headerH, outW, outH);
}
return nullptr;
}
jpeg.close();
Serial.printf("[pipeline] JPEG: %dx%d decoded (channels=%d)\n", imgW, imgH, channels);
// Fit-contain: scale to fit entirely within target dimensions
float fitScaleW = (float)targetW / (float)decodeW;
float fitScaleH = (float)targetH / (float)decodeH;
float fitScaleW = (float)targetW / (float)imgW;
float fitScaleH = (float)targetH / (float)imgH;
float fitScale = fminf(fitScaleW, fitScaleH);
uint16_t fitW = (uint16_t)(decodeW * fitScale);
uint16_t fitH = (uint16_t)(decodeH * fitScale);
uint16_t fitW = (uint16_t)(imgW * fitScale);
uint16_t fitH = (uint16_t)(imgH * fitScale);
Serial.printf("[pipeline] Fit: %dx%d -> %dx%d (scale %.2f)\n",
decodeW, decodeH, fitW, fitH, fitScale);
imgW, imgH, fitW, fitH, fitScale);
// Resize decoded image to fit dimensions
uint8_t* fitted = (uint8_t*)ps_malloc((size_t)fitW * fitH * 3);
if (fitted == nullptr) {
Serial.println("[pipeline] Fit alloc failed");
free(decodedRgb);
stbi_image_free(decoded);
return nullptr;
}
bilinearResize(decodedRgb, (uint16_t)decodeW, (uint16_t)decodeH, fitted, fitW, fitH);
free(decodedRgb);
bilinearResize(decoded, (uint16_t)imgW, (uint16_t)imgH, fitted, fitW, fitH);
stbi_image_free(decoded);
// Allocate final target-sized buffer (zeroed)
size_t finalSize = (size_t)targetW * targetH * 3;
uint8_t* final_buf = (uint8_t*)ps_calloc(targetW * targetH, 3);
if (final_buf == nullptr) {
Serial.println("[pipeline] Final buffer alloc failed");

View File

@@ -1,7 +1,6 @@
#pragma once
#include <Arduino.h>
#include <JPEGDEC.h>
#include "config.h"
struct ProcessedImage {
@@ -11,13 +10,6 @@ struct ProcessedImage {
bool valid;
};
// Context passed through JPEGDEC's user pointer for the draw callback
struct DecodeContext {
uint8_t* rgbBuffer; // Destination RGB888 buffer in PSRAM
uint16_t bufferWidth; // Width of the output buffer
uint16_t bufferHeight; // Height of the output buffer
};
// Pipeline processing mode (epdoptimize recommends one or the other, not both)
enum class PipelineMode : uint8_t {
DYNAMIC, // S-curve tone mapping + saturation boost, no DRC

7988
src/stb_image.h Normal file

File diff suppressed because it is too large Load Diff

15
src/stb_image_impl.cpp Normal file
View File

@@ -0,0 +1,15 @@
#include <esp_heap_caps.h>
#define STB_IMAGE_IMPLEMENTATION
#define STBI_ONLY_JPEG
#define STBI_NO_STDIO
#define STBI_NO_HDR
#define STBI_NO_LINEAR
#define STBI_MALLOC(sz) heap_caps_malloc(sz, MALLOC_CAP_SPIRAM)
#define STBI_REALLOC(p,newsz) heap_caps_realloc(p, newsz, MALLOC_CAP_SPIRAM)
#define STBI_FREE(p) free(p)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function"
#include "stb_image.h"
#pragma GCC diagnostic pop