A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: replace picojpeg with JPEGDEC for JPEG image decoding (#1136)

## Summary

Replaces the picojpeg library with bitbank2/JPEGDEC for JPEG decoding in
the EPUB image pipeline. JPEGDEC provides built-in coarse scaling (1/2,
1/4, 1/8), 8-bit grayscale output, and streaming block-based decoding
via callbacks.

Includes a pre-build patch script for two JPEGDEC changes affecting
progressive JPEG support and EIGHT_BIT_GRAYSCALE mode.

Closes #912

## Additional Context
# Example progressive jpeg

<img
src="https://github.com/user-attachments/assets/e63bb4f8-f862-4aa0-a01f-d1ef43a4b27a"
width="400" height="800" />

Good performance increase from JPEGDEC over picojpeg cc @bitbank2 thanks

## Baseline JPEG Decode Performance: picojpeg vs JPEGDEC (float in
callback) vs JPEGDEC (fixed-point in callback)

Tested with `test_jpeg_images.epub` on device (ESP32-C3), first decode
(no cache).

| Image | Source | Output | picojpeg | JPEGDEC float | JPEGDEC
fixed-point | vs picojpeg | vs float |

|-------|--------|--------|----------|---------------|---------------------|-------------|----------|
| jpeg_format.jpg | 350x250 | 350x250 | 313 ms | 256 ms | **104 ms** |
**3.0x** | **2.5x** |
| grayscale_test.jpg | 400x600 | 400x600 | 768 ms | 661 ms | **246 ms**
| **3.1x** | **2.7x** |
| gradient_test.jpg | 400x500 | 400x500 | 707 ms | 597 ms | **247 ms** |
**2.9x** | **2.4x** |
| centering_test.jpg | 350x400 | 350x400 | 502 ms | 412 ms | **169 ms**
| **3.0x** | **2.4x** |
| scaling_test.jpg | 1200x1500 | 464x580 | 5487 ms | 1114 ms | **668
ms** | **8.2x** | **1.7x** |
| wide_scaling_test.jpg | 1807x736 | 464x188 | 4237 ms | 642 ms | **497
ms** | **8.5x** | **1.3x** |
| cache_test_1.jpg | 400x300 | 400x300 | 422 ms | 348 ms | **141 ms** |
**3.0x** | **2.5x** |
| cache_test_2.jpg | 400x300 | 400x300 | 424 ms | 349 ms | **142 ms** |
**3.0x** | **2.5x** |

### Summary

- **1:1 scale (fixed-point vs float)**: ~2.5x faster — eliminating
software float on the FPU-less ESP32-C3 is the dominant win
- **1:1 scale (fixed-point vs picojpeg)**: ~3.0x faster overall
- **Downscaled images (vs picojpeg)**: 8-9x faster — JPEGDEC's coarse
scaling + fixed-point draw callback
- **Downscaled images (fixed-point vs float)**: 1.3-1.7x — less dramatic
since JPEG library decode dominates over the draw callback for fewer
output pixels
- The fixed-point optimization alone (vs float JPEGDEC) saved **~60% of
render time** on 1:1 images, confirming that software float emulation
was the primary bottleneck in the draw callback
- See thread for discussions on quality of progressive images,
https://github.com/crosspoint-reader/crosspoint-reader/pull/1136#issuecomment-3952952315
- and the conclusion
https://github.com/crosspoint-reader/crosspoint-reader/pull/1136#issuecomment-3959379386
- Proposal to improve quality added at
https://github.com/crosspoint-reader/crosspoint-reader/discussions/1179
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_

---------

Co-authored-by: Dave Allie <dave@daveallie.com>

authored by

martin brook
Dave Allie
and committed by
GitHub
2b25f4d1 a57c62f0

+542 -228
+422 -224
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
··· 2 2 3 3 #include <GfxRenderer.h> 4 4 #include <HalStorage.h> 5 + #include <JPEGDEC.h> 5 6 #include <Logging.h> 6 - #include <picojpeg.h> 7 7 8 - #include <cstdio> 9 - #include <cstring> 8 + #include <cstdlib> 9 + #include <new> 10 10 11 11 #include "DitherUtils.h" 12 12 #include "PixelCache.h" 13 13 14 + namespace { 15 + 16 + // Context struct passed through JPEGDEC callbacks to avoid global mutable state. 17 + // The draw callback receives this via pDraw->pUser (set by setUserPointer()). 18 + // The file I/O callbacks receive the FsFile* via pFile->fHandle (set by jpegOpen()). 14 19 struct JpegContext { 15 - FsFile& file; 16 - uint8_t buffer[512]; 17 - size_t bufferPos; 18 - size_t bufferFilled; 19 - JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {} 20 + GfxRenderer* renderer; 21 + const RenderConfig* config; 22 + int screenWidth; 23 + int screenHeight; 24 + 25 + // Source dimensions after JPEGDEC's built-in scaling 26 + int scaledSrcWidth; 27 + int scaledSrcHeight; 28 + 29 + // Final output dimensions 30 + int dstWidth; 31 + int dstHeight; 32 + 33 + // Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU) 34 + int32_t fineScaleFP; // src -> dst mapping 35 + int32_t invScaleFP; // dst -> src mapping 36 + 37 + PixelCache cache; 38 + bool caching; 39 + 40 + JpegContext() 41 + : renderer(nullptr), 42 + config(nullptr), 43 + screenWidth(0), 44 + screenHeight(0), 45 + scaledSrcWidth(0), 46 + scaledSrcHeight(0), 47 + dstWidth(0), 48 + dstHeight(0), 49 + fineScaleFP(1 << 16), 50 + invScaleFP(1 << 16), 51 + caching(false) {} 20 52 }; 21 53 54 + // File I/O callbacks use pFile->fHandle to access the FsFile*, 55 + // avoiding the need for global file state. 56 + void* jpegOpen(const char* filename, int32_t* size) { 57 + FsFile* f = new FsFile(); 58 + if (!Storage.openFileForRead("JPG", std::string(filename), *f)) { 59 + delete f; 60 + return nullptr; 61 + } 62 + *size = f->size(); 63 + return f; 64 + } 65 + 66 + void jpegClose(void* handle) { 67 + FsFile* f = reinterpret_cast<FsFile*>(handle); 68 + if (f) { 69 + f->close(); 70 + delete f; 71 + } 72 + } 73 + 74 + // JPEGDEC tracks file position via pFile->iPos internally (e.g. JPEGGetMoreData 75 + // checks iPos < iSize to decide whether more data is available). The callbacks 76 + // MUST maintain iPos to match the actual file position, otherwise progressive 77 + // JPEGs with large headers fail during parsing. 78 + int32_t jpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) { 79 + FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle); 80 + if (!f) return 0; 81 + int32_t bytesRead = f->read(pBuf, len); 82 + if (bytesRead < 0) return 0; 83 + pFile->iPos += bytesRead; 84 + return bytesRead; 85 + } 86 + 87 + int32_t jpegSeek(JPEGFILE* pFile, int32_t pos) { 88 + FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle); 89 + if (!f) return -1; 90 + if (!f->seek(pos)) return -1; 91 + pFile->iPos = pos; 92 + return pos; 93 + } 94 + 95 + // JPEGDEC object is ~17 KB due to internal decode buffers. 96 + // Heap-allocate on demand so memory is only used during active decode. 97 + constexpr size_t JPEG_DECODER_APPROX_SIZE = 20 * 1024; 98 + constexpr size_t MIN_FREE_HEAP_FOR_JPEG = JPEG_DECODER_APPROX_SIZE + 16 * 1024; 99 + 100 + // Choose JPEGDEC's built-in scale factor for coarse downscaling. 101 + // Returns the scale denominator (1, 2, 4, or 8) and sets jpegScaleOption. 102 + int chooseJpegScale(float targetScale, int& jpegScaleOption) { 103 + if (targetScale <= 0.125f) { 104 + jpegScaleOption = JPEG_SCALE_EIGHTH; 105 + return 8; 106 + } 107 + if (targetScale <= 0.25f) { 108 + jpegScaleOption = JPEG_SCALE_QUARTER; 109 + return 4; 110 + } 111 + if (targetScale <= 0.5f) { 112 + jpegScaleOption = JPEG_SCALE_HALF; 113 + return 2; 114 + } 115 + jpegScaleOption = 0; 116 + return 1; 117 + } 118 + 119 + // Fixed-point 16.16 arithmetic avoids software float emulation on ESP32-C3 (no FPU). 120 + constexpr int FP_SHIFT = 16; 121 + constexpr int32_t FP_ONE = 1 << FP_SHIFT; 122 + constexpr int32_t FP_MASK = FP_ONE - 1; 123 + 124 + int jpegDrawCallback(JPEGDRAW* pDraw) { 125 + JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser); 126 + if (!ctx || !ctx->config || !ctx->renderer) return 0; 127 + 128 + // In EIGHT_BIT_GRAYSCALE mode, pPixels contains 8-bit grayscale values 129 + // Buffer is densely packed: stride = pDraw->iWidth, valid columns = pDraw->iWidthUsed 130 + uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels); 131 + const int stride = pDraw->iWidth; 132 + const int validW = pDraw->iWidthUsed; 133 + const int blockH = pDraw->iHeight; 134 + 135 + if (stride <= 0 || blockH <= 0 || validW <= 0) return 1; 136 + 137 + const bool useDithering = ctx->config->useDithering; 138 + const bool caching = ctx->caching; 139 + const int32_t fineScaleFP = ctx->fineScaleFP; 140 + const int32_t invScaleFP = ctx->invScaleFP; 141 + GfxRenderer& renderer = *ctx->renderer; 142 + const int cfgX = ctx->config->x; 143 + const int cfgY = ctx->config->y; 144 + const int blockX = pDraw->x; 145 + const int blockY = pDraw->y; 146 + 147 + // Determine destination pixel range covered by this source block 148 + const int srcYEnd = blockY + blockH; 149 + const int srcXEnd = blockX + validW; 150 + 151 + int dstYStart = (int)((int64_t)blockY * fineScaleFP >> FP_SHIFT); 152 + int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFP >> FP_SHIFT); 153 + int dstXStart = (int)((int64_t)blockX * fineScaleFP >> FP_SHIFT); 154 + int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFP >> FP_SHIFT); 155 + 156 + // Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks) 157 + int clampYMax = ctx->dstHeight; 158 + if (ctx->screenHeight - cfgY < clampYMax) clampYMax = ctx->screenHeight - cfgY; 159 + if (dstYStart < -cfgY) dstYStart = -cfgY; 160 + if (dstYEnd > clampYMax) dstYEnd = clampYMax; 161 + 162 + int clampXMax = ctx->dstWidth; 163 + if (ctx->screenWidth - cfgX < clampXMax) clampXMax = ctx->screenWidth - cfgX; 164 + if (dstXStart < -cfgX) dstXStart = -cfgX; 165 + if (dstXEnd > clampXMax) dstXEnd = clampXMax; 166 + 167 + if (dstYStart >= dstYEnd || dstXStart >= dstXEnd) return 1; 168 + 169 + // === 1:1 fast path: no scaling math === 170 + if (fineScaleFP == FP_ONE) { 171 + for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { 172 + const int outY = cfgY + dstY; 173 + const uint8_t* row = &pixels[(dstY - blockY) * stride]; 174 + for (int dstX = dstXStart; dstX < dstXEnd; dstX++) { 175 + const int outX = cfgX + dstX; 176 + uint8_t gray = row[dstX - blockX]; 177 + uint8_t dithered; 178 + if (useDithering) { 179 + dithered = applyBayerDither4Level(gray, outX, outY); 180 + } else { 181 + dithered = gray / 85; 182 + if (dithered > 3) dithered = 3; 183 + } 184 + drawPixelWithRenderMode(renderer, outX, outY, dithered); 185 + if (caching) ctx->cache.setPixel(outX, outY, dithered); 186 + } 187 + } 188 + return 1; 189 + } 190 + 191 + // === Bilinear interpolation (upscale: fineScale > 1.0) === 192 + // Smooths block boundaries that would otherwise create visible banding 193 + // on progressive JPEG DC-only decode (1/8 resolution upscaled to target). 194 + if (fineScaleFP > FP_ONE) { 195 + // Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1]. 196 + // Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping. 197 + int safeXStart = (int)(((int64_t)blockX * fineScaleFP + FP_MASK) >> FP_SHIFT); 198 + int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFP >> FP_SHIFT); 199 + if (safeXStart < dstXStart) safeXStart = dstXStart; 200 + if (safeXEnd > dstXEnd) safeXEnd = dstXEnd; 201 + if (safeXStart > safeXEnd) safeXEnd = safeXStart; 202 + 203 + for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { 204 + const int outY = cfgY + dstY; 205 + const int32_t srcFyFP = dstY * invScaleFP; 206 + const int32_t fy = srcFyFP & FP_MASK; 207 + const int32_t fyInv = FP_ONE - fy; 208 + int ly0 = (srcFyFP >> FP_SHIFT) - blockY; 209 + int ly1 = ly0 + 1; 210 + if (ly0 < 0) ly0 = 0; 211 + if (ly0 >= blockH) ly0 = blockH - 1; 212 + if (ly1 >= blockH) ly1 = blockH - 1; 213 + 214 + const uint8_t* row0 = &pixels[ly0 * stride]; 215 + const uint8_t* row1 = &pixels[ly1 * stride]; 216 + 217 + // Left edge (with X boundary clamping) 218 + for (int dstX = dstXStart; dstX < safeXStart; dstX++) { 219 + const int outX = cfgX + dstX; 220 + const int32_t srcFxFP = dstX * invScaleFP; 221 + const int32_t fx = srcFxFP & FP_MASK; 222 + const int32_t fxInv = FP_ONE - fx; 223 + int lx0 = (srcFxFP >> FP_SHIFT) - blockX; 224 + int lx1 = lx0 + 1; 225 + if (lx0 < 0) lx0 = 0; 226 + if (lx1 < 0) lx1 = 0; 227 + if (lx0 >= validW) lx0 = validW - 1; 228 + if (lx1 >= validW) lx1 = validW - 1; 229 + 230 + int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT; 231 + int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT; 232 + uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT); 233 + 234 + uint8_t dithered; 235 + if (useDithering) { 236 + dithered = applyBayerDither4Level(gray, outX, outY); 237 + } else { 238 + dithered = gray / 85; 239 + if (dithered > 3) dithered = 3; 240 + } 241 + drawPixelWithRenderMode(renderer, outX, outY, dithered); 242 + if (caching) ctx->cache.setPixel(outX, outY, dithered); 243 + } 244 + 245 + // Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds) 246 + for (int dstX = safeXStart; dstX < safeXEnd; dstX++) { 247 + const int outX = cfgX + dstX; 248 + const int32_t srcFxFP = dstX * invScaleFP; 249 + const int32_t fx = srcFxFP & FP_MASK; 250 + const int32_t fxInv = FP_ONE - fx; 251 + const int lx0 = (srcFxFP >> FP_SHIFT) - blockX; 252 + 253 + int top = ((int)row0[lx0] * fxInv + (int)row0[lx0 + 1] * fx) >> FP_SHIFT; 254 + int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT; 255 + uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT); 256 + 257 + uint8_t dithered; 258 + if (useDithering) { 259 + dithered = applyBayerDither4Level(gray, outX, outY); 260 + } else { 261 + dithered = gray / 85; 262 + if (dithered > 3) dithered = 3; 263 + } 264 + drawPixelWithRenderMode(renderer, outX, outY, dithered); 265 + if (caching) ctx->cache.setPixel(outX, outY, dithered); 266 + } 267 + 268 + // Right edge (with X boundary clamping) 269 + for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) { 270 + const int outX = cfgX + dstX; 271 + const int32_t srcFxFP = dstX * invScaleFP; 272 + const int32_t fx = srcFxFP & FP_MASK; 273 + const int32_t fxInv = FP_ONE - fx; 274 + int lx0 = (srcFxFP >> FP_SHIFT) - blockX; 275 + int lx1 = lx0 + 1; 276 + if (lx0 >= validW) lx0 = validW - 1; 277 + if (lx1 >= validW) lx1 = validW - 1; 278 + 279 + int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT; 280 + int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT; 281 + uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT); 282 + 283 + uint8_t dithered; 284 + if (useDithering) { 285 + dithered = applyBayerDither4Level(gray, outX, outY); 286 + } else { 287 + dithered = gray / 85; 288 + if (dithered > 3) dithered = 3; 289 + } 290 + drawPixelWithRenderMode(renderer, outX, outY, dithered); 291 + if (caching) ctx->cache.setPixel(outX, outY, dithered); 292 + } 293 + } 294 + return 1; 295 + } 296 + 297 + // === Nearest-neighbor (downscale: fineScale < 1.0) === 298 + for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { 299 + const int outY = cfgY + dstY; 300 + const int32_t srcFyFP = dstY * invScaleFP; 301 + int ly = (srcFyFP >> FP_SHIFT) - blockY; 302 + if (ly < 0) ly = 0; 303 + if (ly >= blockH) ly = blockH - 1; 304 + const uint8_t* row = &pixels[ly * stride]; 305 + 306 + for (int dstX = dstXStart; dstX < dstXEnd; dstX++) { 307 + const int outX = cfgX + dstX; 308 + const int32_t srcFxFP = dstX * invScaleFP; 309 + int lx = (srcFxFP >> FP_SHIFT) - blockX; 310 + if (lx < 0) lx = 0; 311 + if (lx >= validW) lx = validW - 1; 312 + uint8_t gray = row[lx]; 313 + 314 + uint8_t dithered; 315 + if (useDithering) { 316 + dithered = applyBayerDither4Level(gray, outX, outY); 317 + } else { 318 + dithered = gray / 85; 319 + if (dithered > 3) dithered = 3; 320 + } 321 + drawPixelWithRenderMode(renderer, outX, outY, dithered); 322 + if (caching) ctx->cache.setPixel(outX, outY, dithered); 323 + } 324 + } 325 + 326 + return 1; 327 + } 328 + 329 + } // namespace 330 + 22 331 bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) { 23 - FsFile file; 24 - if (!Storage.openFileForRead("JPG", imagePath, file)) { 25 - LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str()); 332 + size_t freeHeap = ESP.getFreeHeap(); 333 + if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) { 334 + LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG); 26 335 return false; 27 336 } 28 337 29 - JpegContext context(file); 30 - pjpeg_image_info_t imageInfo; 338 + JPEGDEC* jpeg = new (std::nothrow) JPEGDEC(); 339 + if (!jpeg) { 340 + LOG_ERR("JPG", "Failed to allocate JPEG decoder for dimensions"); 341 + return false; 342 + } 31 343 32 - int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); 33 - file.close(); 34 - 35 - if (status != 0) { 36 - LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status); 344 + int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, nullptr); 345 + if (rc != 1) { 346 + LOG_ERR("JPG", "Failed to open JPEG for dimensions (err=%d): %s", jpeg->getLastError(), imagePath.c_str()); 347 + delete jpeg; 37 348 return false; 38 349 } 39 350 40 - out.width = imageInfo.m_width; 41 - out.height = imageInfo.m_height; 351 + out.width = jpeg->getWidth(); 352 + out.height = jpeg->getHeight(); 42 353 LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height); 354 + 355 + jpeg->close(); 356 + delete jpeg; 43 357 return true; 44 358 } 45 359 ··· 47 361 const RenderConfig& config) { 48 362 LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str()); 49 363 50 - FsFile file; 51 - if (!Storage.openFileForRead("JPG", imagePath, file)) { 52 - LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str()); 364 + size_t freeHeap = ESP.getFreeHeap(); 365 + if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) { 366 + LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG); 367 + return false; 368 + } 369 + 370 + JPEGDEC* jpeg = new (std::nothrow) JPEGDEC(); 371 + if (!jpeg) { 372 + LOG_ERR("JPG", "Failed to allocate JPEG decoder"); 373 + return false; 374 + } 375 + 376 + JpegContext ctx; 377 + ctx.renderer = &renderer; 378 + ctx.config = &config; 379 + ctx.screenWidth = renderer.getScreenWidth(); 380 + ctx.screenHeight = renderer.getScreenHeight(); 381 + 382 + int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, jpegDrawCallback); 383 + if (rc != 1) { 384 + LOG_ERR("JPG", "Failed to open JPEG (err=%d): %s", jpeg->getLastError(), imagePath.c_str()); 385 + delete jpeg; 53 386 return false; 54 387 } 55 388 56 - JpegContext context(file); 57 - pjpeg_image_info_t imageInfo; 389 + int srcWidth = jpeg->getWidth(); 390 + int srcHeight = jpeg->getHeight(); 58 391 59 - int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); 60 - if (status != 0) { 61 - LOG_ERR("JPG", "picojpeg init failed: %d", status); 62 - file.close(); 392 + if (srcWidth <= 0 || srcHeight <= 0) { 393 + LOG_ERR("JPG", "Invalid JPEG dimensions: %dx%d", srcWidth, srcHeight); 394 + jpeg->close(); 395 + delete jpeg; 63 396 return false; 64 397 } 65 398 66 - if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) { 67 - file.close(); 399 + if (!validateImageDimensions(srcWidth, srcHeight, "JPEG")) { 400 + jpeg->close(); 401 + delete jpeg; 68 402 return false; 69 403 } 70 404 71 - // Calculate output dimensions 405 + bool isProgressive = jpeg->getJPEGType() == JPEG_MODE_PROGRESSIVE; 406 + if (isProgressive) { 407 + LOG_INF("JPG", "Progressive JPEG detected - decoding DC coefficients only (lower quality)"); 408 + } 409 + 410 + // Calculate overall target scale 411 + float targetScale; 72 412 int destWidth, destHeight; 73 - float scale; 74 413 75 414 if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) { 76 - // Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes) 77 415 destWidth = config.maxWidth; 78 416 destHeight = config.maxHeight; 79 - scale = (float)destWidth / imageInfo.m_width; 417 + targetScale = (float)destWidth / srcWidth; 80 418 } else { 81 - // Calculate scale factor to fit within maxWidth/maxHeight 82 - float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth) 83 - ? (float)config.maxWidth / imageInfo.m_width 84 - : 1.0f; 85 - float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight) 86 - ? (float)config.maxHeight / imageInfo.m_height 87 - : 1.0f; 88 - scale = (scaleX < scaleY) ? scaleX : scaleY; 89 - if (scale > 1.0f) scale = 1.0f; 419 + float scaleX = (config.maxWidth > 0 && srcWidth > config.maxWidth) ? (float)config.maxWidth / srcWidth : 1.0f; 420 + float scaleY = (config.maxHeight > 0 && srcHeight > config.maxHeight) ? (float)config.maxHeight / srcHeight : 1.0f; 421 + targetScale = (scaleX < scaleY) ? scaleX : scaleY; 422 + if (targetScale > 1.0f) targetScale = 1.0f; 423 + 424 + destWidth = (int)(srcWidth * targetScale); 425 + destHeight = (int)(srcHeight * targetScale); 426 + } 90 427 91 - destWidth = (int)(imageInfo.m_width * scale); 92 - destHeight = (int)(imageInfo.m_height * scale); 428 + // Choose JPEGDEC built-in scaling for coarse downscaling. 429 + // Progressive JPEGs: JPEGDEC forces JPEG_SCALE_EIGHTH internally (DC-only 430 + // decode produces 1/8 resolution). We must match this to avoid the if/else 431 + // priority chain in DecodeJPEG selecting a different scale. 432 + int jpegScaleOption; 433 + int jpegScaleDenom; 434 + if (isProgressive) { 435 + jpegScaleOption = JPEG_SCALE_EIGHTH; 436 + jpegScaleDenom = 8; 437 + } else { 438 + jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption); 93 439 } 94 440 95 - LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height, 96 - destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight); 441 + ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom; 442 + ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom; 443 + ctx.dstWidth = destWidth; 444 + ctx.dstHeight = destHeight; 445 + ctx.fineScaleFP = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth); 446 + ctx.invScaleFP = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth); 97 447 98 - if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) { 99 - LOG_ERR("JPG", "Null buffer pointers in imageInfo"); 100 - file.close(); 101 - return false; 102 - } 448 + LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth, 449 + destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth, 450 + isProgressive ? " [progressive]" : ""); 103 451 104 - const int screenWidth = renderer.getScreenWidth(); 105 - const int screenHeight = renderer.getScreenHeight(); 452 + // Set pixel type to 8-bit grayscale (must be after open()) 453 + jpeg->setPixelType(EIGHT_BIT_GRAYSCALE); 454 + jpeg->setUserPointer(&ctx); 106 455 107 - // Allocate pixel cache if cachePath is provided 108 - PixelCache cache; 109 - bool caching = !config.cachePath.empty(); 110 - if (caching) { 111 - if (!cache.allocate(destWidth, destHeight, config.x, config.y)) { 456 + // Allocate cache buffer using final output dimensions 457 + ctx.caching = !config.cachePath.empty(); 458 + if (ctx.caching) { 459 + if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) { 112 460 LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching"); 113 - caching = false; 461 + ctx.caching = false; 114 462 } 115 463 } 116 464 117 - int mcuX = 0; 118 - int mcuY = 0; 465 + unsigned long decodeStart = millis(); 466 + rc = jpeg->decode(0, 0, jpegScaleOption); 467 + unsigned long decodeTime = millis() - decodeStart; 119 468 120 - while (mcuY < imageInfo.m_MCUSPerCol) { 121 - status = pjpeg_decode_mcu(); 122 - if (status == PJPG_NO_MORE_BLOCKS) { 123 - break; 124 - } 125 - if (status != 0) { 126 - LOG_ERR("JPG", "MCU decode failed: %d", status); 127 - file.close(); 128 - return false; 129 - } 130 - 131 - // Source position in image coordinates 132 - int srcStartX = mcuX * imageInfo.m_MCUWidth; 133 - int srcStartY = mcuY * imageInfo.m_MCUHeight; 134 - 135 - switch (imageInfo.m_scanType) { 136 - case PJPG_GRAYSCALE: 137 - for (int row = 0; row < 8; row++) { 138 - int srcY = srcStartY + row; 139 - int destY = config.y + (int)(srcY * scale); 140 - if (destY >= screenHeight || destY >= config.y + destHeight) continue; 141 - for (int col = 0; col < 8; col++) { 142 - int srcX = srcStartX + col; 143 - int destX = config.x + (int)(srcX * scale); 144 - if (destX >= screenWidth || destX >= config.x + destWidth) continue; 145 - uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col]; 146 - uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 147 - if (dithered > 3) dithered = 3; 148 - drawPixelWithRenderMode(renderer, destX, destY, dithered); 149 - if (caching) cache.setPixel(destX, destY, dithered); 150 - } 151 - } 152 - break; 153 - 154 - case PJPG_YH1V1: 155 - for (int row = 0; row < 8; row++) { 156 - int srcY = srcStartY + row; 157 - int destY = config.y + (int)(srcY * scale); 158 - if (destY >= screenHeight || destY >= config.y + destHeight) continue; 159 - for (int col = 0; col < 8; col++) { 160 - int srcX = srcStartX + col; 161 - int destX = config.x + (int)(srcX * scale); 162 - if (destX >= screenWidth || destX >= config.x + destWidth) continue; 163 - uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col]; 164 - uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col]; 165 - uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col]; 166 - uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 167 - uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 168 - if (dithered > 3) dithered = 3; 169 - drawPixelWithRenderMode(renderer, destX, destY, dithered); 170 - if (caching) cache.setPixel(destX, destY, dithered); 171 - } 172 - } 173 - break; 174 - 175 - case PJPG_YH2V1: 176 - for (int row = 0; row < 8; row++) { 177 - int srcY = srcStartY + row; 178 - int destY = config.y + (int)(srcY * scale); 179 - if (destY >= screenHeight || destY >= config.y + destHeight) continue; 180 - for (int col = 0; col < 16; col++) { 181 - int srcX = srcStartX + col; 182 - int destX = config.x + (int)(srcX * scale); 183 - if (destX >= screenWidth || destX >= config.x + destWidth) continue; 184 - int blockIndex = (col < 8) ? 0 : 1; 185 - int pixelIndex = row * 8 + (col % 8); 186 - uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex]; 187 - uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex]; 188 - uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex]; 189 - uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 190 - uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 191 - if (dithered > 3) dithered = 3; 192 - drawPixelWithRenderMode(renderer, destX, destY, dithered); 193 - if (caching) cache.setPixel(destX, destY, dithered); 194 - } 195 - } 196 - break; 197 - 198 - case PJPG_YH1V2: 199 - for (int row = 0; row < 16; row++) { 200 - int srcY = srcStartY + row; 201 - int destY = config.y + (int)(srcY * scale); 202 - if (destY >= screenHeight || destY >= config.y + destHeight) continue; 203 - for (int col = 0; col < 8; col++) { 204 - int srcX = srcStartX + col; 205 - int destX = config.x + (int)(srcX * scale); 206 - if (destX >= screenWidth || destX >= config.x + destWidth) continue; 207 - int blockIndex = (row < 8) ? 0 : 1; 208 - int pixelIndex = (row % 8) * 8 + col; 209 - uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex]; 210 - uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex]; 211 - uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex]; 212 - uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 213 - uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 214 - if (dithered > 3) dithered = 3; 215 - drawPixelWithRenderMode(renderer, destX, destY, dithered); 216 - if (caching) cache.setPixel(destX, destY, dithered); 217 - } 218 - } 219 - break; 220 - 221 - case PJPG_YH2V2: 222 - for (int row = 0; row < 16; row++) { 223 - int srcY = srcStartY + row; 224 - int destY = config.y + (int)(srcY * scale); 225 - if (destY >= screenHeight || destY >= config.y + destHeight) continue; 226 - for (int col = 0; col < 16; col++) { 227 - int srcX = srcStartX + col; 228 - int destX = config.x + (int)(srcX * scale); 229 - if (destX >= screenWidth || destX >= config.x + destWidth) continue; 230 - int blockX = (col < 8) ? 0 : 1; 231 - int blockY = (row < 8) ? 0 : 1; 232 - int blockIndex = blockY * 2 + blockX; 233 - int pixelIndex = (row % 8) * 8 + (col % 8); 234 - int blockOffset = blockIndex * 64; 235 - uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex]; 236 - uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex]; 237 - uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex]; 238 - uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 239 - uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 240 - if (dithered > 3) dithered = 3; 241 - drawPixelWithRenderMode(renderer, destX, destY, dithered); 242 - if (caching) cache.setPixel(destX, destY, dithered); 243 - } 244 - } 245 - break; 246 - } 247 - 248 - mcuX++; 249 - if (mcuX >= imageInfo.m_MCUSPerRow) { 250 - mcuX = 0; 251 - mcuY++; 252 - } 469 + if (rc != 1) { 470 + LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError()); 471 + jpeg->close(); 472 + delete jpeg; 473 + return false; 253 474 } 254 475 255 - LOG_DBG("JPG", "Decoding complete"); 256 - file.close(); 476 + jpeg->close(); 477 + delete jpeg; 478 + LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime); 257 479 258 480 // Write cache file if caching was enabled 259 - if (caching) { 260 - cache.writeToFile(config.cachePath); 481 + if (ctx.caching) { 482 + ctx.cache.writeToFile(config.cachePath); 261 483 } 262 484 263 485 return true; 264 - } 265 - 266 - unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, 267 - unsigned char* pBytes_actually_read, void* pCallback_data) { 268 - JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data); 269 - 270 - if (context->bufferPos >= context->bufferFilled) { 271 - int readCount = context->file.read(context->buffer, sizeof(context->buffer)); 272 - if (readCount <= 0) { 273 - *pBytes_actually_read = 0; 274 - return 0; 275 - } 276 - context->bufferFilled = readCount; 277 - context->bufferPos = 0; 278 - } 279 - 280 - unsigned int bytesAvailable = context->bufferFilled - context->bufferPos; 281 - unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size; 282 - 283 - memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy); 284 - context->bufferPos += bytesToCopy; 285 - *pBytes_actually_read = bytesToCopy; 286 - 287 - return 0; 288 486 } 289 487 290 488 bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
+1 -4
lib/Epub/Epub/converters/JpegToFramebufferConverter.h
··· 1 1 #pragma once 2 + 2 3 #include <stdint.h> 3 4 4 5 #include <string> ··· 17 18 18 19 static bool supportsFormat(const std::string& extension); 19 20 const char* getFormatName() const override { return "JPEG"; } 20 - 21 - private: 22 - static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, 23 - unsigned char* pBytes_actually_read, void* pCallback_data); 24 21 };
+2
platformio.ini
··· 46 46 extra_scripts = 47 47 pre:scripts/build_html.py 48 48 pre:scripts/gen_i18n.py 49 + pre:scripts/patch_jpegdec.py 49 50 pre:scripts/git_branch.py 50 51 51 52 ; Libraries ··· 57 58 bblanchon/ArduinoJson @ 7.4.2 58 59 ricmoo/QRCode @ 0.0.1 59 60 bitbank2/PNGdec @ ^1.0.0 61 + bitbank2/JPEGDEC @ ^1.8.0 60 62 links2004/WebSockets @ 2.7.3 61 63 62 64 [env:default]
+117
scripts/patch_jpegdec.py
··· 1 + """ 2 + PlatformIO pre-build script: patch JPEGDEC library for progressive JPEG support. 3 + 4 + Two patches are applied: 5 + 6 + 1. JPEGMakeHuffTables: Skip AC Huffman table construction for progressive JPEGs. 7 + JPEGDEC 1.8.x fails to open progressive JPEGs because JPEGMakeHuffTables() 8 + cannot build AC tables with 11+-bit codes (the "slow tables" path is disabled). 9 + Since progressive decode only uses DC coefficients, AC tables are not needed. 10 + 11 + 2. JPEGDecodeMCU_P: Guard pMCU writes against MCU_SKIP (-8). 12 + The non-progressive JPEGDecodeMCU checks `iMCU >= 0` before writing to pMCU, 13 + but JPEGDecodeMCU_P does not. When EIGHT_BIT_GRAYSCALE mode skips chroma 14 + channels by passing MCU_SKIP, the unguarded write goes to a wild pointer 15 + (sMCUs[0xFFFFF8]) and crashes. 16 + 17 + Both patches are applied idempotently so it is safe to run on every build. 18 + """ 19 + 20 + Import("env") 21 + import os 22 + 23 + def patch_jpegdec(env): 24 + # Find the JPEGDEC library in libdeps 25 + libdeps_dir = os.path.join(env["PROJECT_DIR"], ".pio", "libdeps") 26 + if not os.path.isdir(libdeps_dir): 27 + return 28 + for env_dir in os.listdir(libdeps_dir): 29 + jpeg_inl = os.path.join(libdeps_dir, env_dir, "JPEGDEC", "src", "jpeg.inl") 30 + if os.path.isfile(jpeg_inl): 31 + _apply_ac_table_patch(jpeg_inl) 32 + _apply_mcu_skip_patch(jpeg_inl) 33 + 34 + def _apply_ac_table_patch(filepath): 35 + MARKER = "// CrossPoint patch: skip AC tables for progressive JPEG" 36 + with open(filepath, "r") as f: 37 + content = f.read() 38 + 39 + if MARKER in content: 40 + return # already patched 41 + 42 + OLD = """\ 43 + } 44 + // now do AC components (up to 4 tables of 16-bit codes)""" 45 + 46 + NEW = """\ 47 + } 48 + """ + MARKER + """ 49 + // Progressive JPEG: only DC coefficients are decoded (first scan), so AC 50 + // Huffman tables are not needed. Skip building them to avoid failing on 51 + // 11+-bit AC codes that the optimized table builder cannot handle. 52 + if (pJPEG->ucMode == 0xc2) 53 + return 1; 54 + // now do AC components (up to 4 tables of 16-bit codes)""" 55 + 56 + if OLD not in content: 57 + print("WARNING: JPEGDEC AC table patch target not found in %s — library may have been updated" % filepath) 58 + return 59 + 60 + content = content.replace(OLD, NEW, 1) 61 + with open(filepath, "w") as f: 62 + f.write(content) 63 + print("Patched JPEGDEC: skip AC tables for progressive JPEG: %s" % filepath) 64 + 65 + def _apply_mcu_skip_patch(filepath): 66 + MARKER = "// CrossPoint patch: guard pMCU write for MCU_SKIP" 67 + with open(filepath, "r") as f: 68 + content = f.read() 69 + 70 + if MARKER in content: 71 + return # already patched 72 + 73 + # Patch 1: Guard the unconditional pMCU[0] write in JPEGDecodeMCU_P. 74 + # This is the DC coefficient store that crashes when iMCU = MCU_SKIP (-8). 75 + OLD_DC = """\ 76 + pMCU[0] = (short)*iDCPredictor; // store in MCU[0] 77 + } 78 + // Now get the other 63 AC coefficients""" 79 + 80 + NEW_DC = """\ 81 + """ + MARKER + """ 82 + if (iMCU >= 0) 83 + pMCU[0] = (short)*iDCPredictor; // store in MCU[0] 84 + } 85 + // Now get the other 63 AC coefficients""" 86 + 87 + if OLD_DC not in content: 88 + print("WARNING: JPEGDEC MCU_SKIP patch target not found in %s — library may have been updated" % filepath) 89 + return 90 + 91 + content = content.replace(OLD_DC, NEW_DC, 1) 92 + 93 + # Patch 2: Guard the successive approximation pMCU[0] write. 94 + # This path is taken on subsequent scans (cApproxBitsHigh != 0), which we 95 + # don't normally hit (we only decode first scan), but guard it for safety. 96 + OLD_SA = """\ 97 + pMCU[0] |= iPositive; 98 + } 99 + goto mcu_done; // that's it""" 100 + 101 + NEW_SA = """\ 102 + if (iMCU >= 0) 103 + pMCU[0] |= iPositive; 104 + } 105 + goto mcu_done; // that's it""" 106 + 107 + if OLD_SA in content: 108 + content = content.replace(OLD_SA, NEW_SA, 1) 109 + 110 + with open(filepath, "w") as f: 111 + f.write(content) 112 + print("Patched JPEGDEC: guard pMCU writes for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath) 113 + 114 + # Apply patches immediately when this pre: script runs, before compilation starts. 115 + # Previously used env.AddPreAction("buildprog", ...) which deferred patching until 116 + # the link step — after the library was already compiled from unpatched source. 117 + patch_jpegdec(env)