#include "JpegToBmpConverter.h" #include #include #include #include #include #include #include #include #include #include "BitmapHelpers.h" // ============================================================================ // IMAGE PROCESSING OPTIONS - Toggle these to test different configurations // ============================================================================ constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels) // Dithering method selection (only one should be true, or all false for simple quantization): constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering // ============================================================================ inline void write16(Print& out, const uint16_t value) { out.write(value & 0xFF); out.write((value >> 8) & 0xFF); } inline void write32(Print& out, const uint32_t value) { out.write(value & 0xFF); out.write((value >> 8) & 0xFF); out.write((value >> 16) & 0xFF); out.write((value >> 24) & 0xFF); } inline void write32Signed(Print& out, const int32_t value) { out.write(value & 0xFF); out.write((value >> 8) & 0xFF); out.write((value >> 16) & 0xFF); out.write((value >> 24) & 0xFF); } // Helper function: Write BMP header with 8-bit grayscale (256 levels) void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) const int bytesPerRow = (width + 3) / 4 * 4; // 8 bits per pixel, padded const int imageSize = bytesPerRow * height; const uint32_t paletteSize = 256 * 4; // 256 colors * 4 bytes (BGRA) const uint32_t fileSize = 14 + 40 + paletteSize + imageSize; // BMP File Header (14 bytes) bmpOut.write('B'); bmpOut.write('M'); write32(bmpOut, fileSize); write32(bmpOut, 0); // Reserved write32(bmpOut, 14 + 40 + paletteSize); // Offset to pixel data // DIB Header (BITMAPINFOHEADER - 40 bytes) write32(bmpOut, 40); write32Signed(bmpOut, width); write32Signed(bmpOut, -height); // Negative height = top-down bitmap write16(bmpOut, 1); // Color planes write16(bmpOut, 8); // Bits per pixel (8 bits) write32(bmpOut, 0); // BI_RGB (no compression) write32(bmpOut, imageSize); write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) write32(bmpOut, 256); // colorsUsed write32(bmpOut, 256); // colorsImportant // Color Palette (256 grayscale entries x 4 bytes = 1024 bytes) for (int i = 0; i < 256; i++) { bmpOut.write(static_cast(i)); // Blue bmpOut.write(static_cast(i)); // Green bmpOut.write(static_cast(i)); // Red bmpOut.write(static_cast(0)); // Reserved } } // Helper function: Write BMP header with 1-bit color depth (black and white) static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary const int imageSize = bytesPerRow * height; const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image // BMP File Header (14 bytes) bmpOut.write('B'); bmpOut.write('M'); write32(bmpOut, fileSize); // File size write32(bmpOut, 0); // Reserved write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8) // DIB Header (BITMAPINFOHEADER - 40 bytes) write32(bmpOut, 40); write32Signed(bmpOut, width); write32Signed(bmpOut, -height); // Negative height = top-down bitmap write16(bmpOut, 1); // Color planes write16(bmpOut, 1); // Bits per pixel (1 bit) write32(bmpOut, 0); // BI_RGB (no compression) write32(bmpOut, imageSize); write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) write32(bmpOut, 2); // colorsUsed write32(bmpOut, 2); // colorsImportant // Color Palette (2 colors x 4 bytes = 8 bytes) // Format: Blue, Green, Red, Reserved (BGRA) // Note: In 1-bit BMP, palette index 0 = black, 1 = white uint8_t palette[8] = { 0x00, 0x00, 0x00, 0x00, // Color 0: Black 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White }; for (const uint8_t i : palette) { bmpOut.write(i); } } // Helper function: Write BMP header with 2-bit color depth static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up const int imageSize = bytesPerRow * height; const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image // BMP File Header (14 bytes) bmpOut.write('B'); bmpOut.write('M'); write32(bmpOut, fileSize); // File size write32(bmpOut, 0); // Reserved write32(bmpOut, 70); // Offset to pixel data // DIB Header (BITMAPINFOHEADER - 40 bytes) write32(bmpOut, 40); write32Signed(bmpOut, width); write32Signed(bmpOut, -height); // Negative height = top-down bitmap write16(bmpOut, 1); // Color planes write16(bmpOut, 2); // Bits per pixel (2 bits) write32(bmpOut, 0); // BI_RGB (no compression) write32(bmpOut, imageSize); write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) write32(bmpOut, 4); // colorsUsed write32(bmpOut, 4); // colorsImportant // Color Palette (4 colors x 4 bytes = 16 bytes) // Format: Blue, Green, Red, Reserved (BGRA) uint8_t palette[16] = { 0x00, 0x00, 0x00, 0x00, // Color 0: Black 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White }; for (const uint8_t i : palette) { bmpOut.write(i); } } namespace { // Max MCU height supported by any JPEG (4:2:0 chroma = 16 rows, 4:4:4 = 8 rows) constexpr int MAX_MCU_HEIGHT = 16; constexpr size_t JPEG_DECODER_SIZE = 20 * 1024; constexpr size_t MIN_FREE_HEAP = JPEG_DECODER_SIZE + 32 * 1024; constexpr uint32_t FP_ONE = 1UL << 16; // Static file pointer for JPEGDEC open callback. // Safe in single-threaded embedded context; never accessed concurrently. static HalFile* s_jpegFile = nullptr; static uint8_t s_jpegIoSinceYield = 0; static void yieldToIdle() { vTaskDelay(1); } static void yieldDuringJpegIo() { if (++s_jpegIoSinceYield < 4) return; s_jpegIoSinceYield = 0; yieldToIdle(); } void* bmpJpegOpen(const char* /*filename*/, int32_t* size) { if (!s_jpegFile || !*s_jpegFile) return nullptr; s_jpegIoSinceYield = 0; s_jpegFile->seek(0); *size = static_cast(s_jpegFile->size()); yieldDuringJpegIo(); return s_jpegFile; } void bmpJpegClose(void* /*handle*/) { // Caller owns the file — do not close it here } int32_t bmpJpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) { auto* f = reinterpret_cast(pFile->fHandle); if (!f) return 0; int32_t n = f->read(pBuf, len); if (n < 0) n = 0; pFile->iPos += n; yieldDuringJpegIo(); return n; } int32_t bmpJpegSeek(JPEGFILE* pFile, int32_t pos) { auto* f = reinterpret_cast(pFile->fHandle); if (!f || !f->seek(pos)) return -1; pFile->iPos = pos; yieldDuringJpegIo(); return pos; } // Context passed to the JPEGDEC draw callback via setUserPointer() struct BmpConvertCtx { Print* bmpOut; int srcWidth; int srcHeight; int outWidth; int outHeight; bool oneBit; int bytesPerRow; bool needsScaling; uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point uint32_t scaleY_fp; bool smoothUpscale; uint32_t smoothScaleX_fp; uint32_t smoothScaleY_fp; // Accumulates one MCU row (up to MAX_MCU_HEIGHT source rows × srcWidth pixels) // Filled column-by-column as JPEGDEC callbacks arrive for the same MCU row std::unique_ptr mcuBuf; // Y-axis area averaging accumulators (needsScaling only) int currentOutY; uint32_t nextOutY_srcStart; // 16.16 fixed-point boundary for the next output row std::unique_ptr rowAccum; std::unique_ptr rowCount; int smoothNextOutY; int smoothPrevY; std::unique_ptr smoothRows; uint8_t* smoothPrevRow; uint8_t* smoothCurrRow; uint8_t* smoothOutRow; std::unique_ptr bmpRow; std::unique_ptr atkinsonDitherer; std::unique_ptr fsDitherer; std::unique_ptr atkinson1BitDitherer; uint8_t rowsSinceYield; uint8_t blocksSinceYield; bool error; }; static void yieldDuringDecode(BmpConvertCtx* ctx) { if (++ctx->rowsSinceYield < 8) return; ctx->rowsSinceYield = 0; yieldToIdle(); } static void yieldDuringDecodeBlock(BmpConvertCtx* ctx) { if (++ctx->blocksSinceYield < 16) return; ctx->blocksSinceYield = 0; yieldToIdle(); } // Write a fully-assembled output row (grayscale bytes, length outWidth) to BMP static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) { memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow); if (USE_8BIT_OUTPUT && !ctx->oneBit) { for (int x = 0; x < ctx->outWidth; x++) { ctx->bmpRow[x] = adjustPixel(srcRow[x]); } } else if (ctx->oneBit) { for (int x = 0; x < ctx->outWidth; x++) { const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(srcRow[x], x) : quantize1bit(srcRow[x], x, outY); ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8))); } if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); } else { for (int x = 0; x < ctx->outWidth; x++) { const uint8_t gray = adjustPixel(srcRow[x]); uint8_t twoBit; if (ctx->atkinsonDitherer) { twoBit = ctx->atkinsonDitherer->processPixel(gray, x); } else if (ctx->fsDitherer) { twoBit = ctx->fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, outY); } ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); } if (ctx->atkinsonDitherer) ctx->atkinsonDitherer->nextRow(); else if (ctx->fsDitherer) ctx->fsDitherer->nextRow(); } ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow); yieldDuringDecode(ctx); } // Matches the progressive-JPEG smoothing used by JpegToFramebufferConverter, but stays // local because cover generation streams dithered BMP rows instead of framebuffer pixels. static uint32_t interpolationStep(const int srcSize, const int outSize) { if (srcSize <= 1 || outSize <= 1) return 0; return (static_cast(srcSize - 1) << 16) / static_cast(outSize - 1); } static uint32_t interpolatedSourceFp(const int outIndex, const int outSize, const int srcSize, const uint32_t step) { if (srcSize <= 1 || outSize <= 1) return 0; if (outIndex >= outSize - 1) return static_cast(srcSize - 1) << 16; return static_cast(outIndex) * step; } static void scaleRowLinear(BmpConvertCtx* ctx, const uint8_t* srcRow, uint8_t* dstRow) { for (int outX = 0; outX < ctx->outWidth; outX++) { const uint32_t srcX_fp = interpolatedSourceFp(outX, ctx->outWidth, ctx->srcWidth, ctx->smoothScaleX_fp); const int x0 = srcX_fp >> 16; const int x1 = (x0 + 1 < ctx->srcWidth) ? (x0 + 1) : x0; const uint32_t fx = srcX_fp & (FP_ONE - 1); dstRow[outX] = static_cast((srcRow[x0] * (FP_ONE - fx) + srcRow[x1] * fx) >> 16); } } static void writeBlendedRow(BmpConvertCtx* ctx, const uint8_t* row0, const uint8_t* row1, const uint32_t fy, const int outY) { const uint32_t invFy = FP_ONE - fy; for (int outX = 0; outX < ctx->outWidth; outX++) { ctx->smoothOutRow[outX] = static_cast((row0[outX] * invFy + row1[outX] * fy) >> 16); } writeOutputRow(ctx, ctx->smoothOutRow, outY); } static void processSmoothSourceRow(BmpConvertCtx* ctx, const uint8_t* srcRow, const int srcY) { scaleRowLinear(ctx, srcRow, ctx->smoothCurrRow); if (ctx->smoothPrevY < 0) { uint8_t* tmp = ctx->smoothPrevRow; ctx->smoothPrevRow = ctx->smoothCurrRow; ctx->smoothCurrRow = tmp; ctx->smoothPrevY = srcY; if (ctx->srcHeight <= 1) { while (ctx->smoothNextOutY < ctx->outHeight) { writeOutputRow(ctx, ctx->smoothPrevRow, ctx->smoothNextOutY); ctx->smoothNextOutY++; } return; } return; } while (ctx->smoothNextOutY < ctx->outHeight) { const uint32_t srcY_fp = interpolatedSourceFp(ctx->smoothNextOutY, ctx->outHeight, ctx->srcHeight, ctx->smoothScaleY_fp); const int y0 = srcY_fp >> 16; const int y1 = (y0 + 1 < ctx->srcHeight) ? (y0 + 1) : y0; if (y1 > srcY) break; const uint8_t* row0 = (y0 == srcY) ? ctx->smoothCurrRow : ctx->smoothPrevRow; const uint8_t* row1 = (y1 == srcY) ? ctx->smoothCurrRow : ctx->smoothPrevRow; writeBlendedRow(ctx, row0, row1, srcY_fp & (FP_ONE - 1), ctx->smoothNextOutY); ctx->smoothNextOutY++; } uint8_t* tmp = ctx->smoothPrevRow; ctx->smoothPrevRow = ctx->smoothCurrRow; ctx->smoothCurrRow = tmp; ctx->smoothPrevY = srcY; } static void finishSmoothUpscale(BmpConvertCtx* ctx) { if (ctx->smoothPrevY < 0) { LOG_ERR("JPG", "No progressive rows decoded for smoothing"); ctx->error = true; return; } while (ctx->smoothNextOutY < ctx->outHeight) { writeOutputRow(ctx, ctx->smoothPrevRow, ctx->smoothNextOutY); ctx->smoothNextOutY++; } } // Flush one scaled output row from Y-axis accumulators and advance currentOutY static void flushScaledRow(BmpConvertCtx* ctx) { memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow); if (USE_8BIT_OUTPUT && !ctx->oneBit) { for (int x = 0; x < ctx->outWidth; x++) { const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; ctx->bmpRow[x] = adjustPixel(gray); } } else if (ctx->oneBit) { for (int x = 0; x < ctx->outWidth; x++) { const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, ctx->currentOutY); ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8))); } if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); } else { for (int x = 0; x < ctx->outWidth; x++) { const uint8_t gray = adjustPixel((ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0); uint8_t twoBit; if (ctx->atkinsonDitherer) { twoBit = ctx->atkinsonDitherer->processPixel(gray, x); } else if (ctx->fsDitherer) { twoBit = ctx->fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, ctx->currentOutY); } ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); } if (ctx->atkinsonDitherer) ctx->atkinsonDitherer->nextRow(); else if (ctx->fsDitherer) ctx->fsDitherer->nextRow(); } ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow); ctx->currentOutY++; yieldDuringDecode(ctx); } // JPEGDEC draw callback — receives one MCU-width × MCU-height block at a time, // in left-to-right, top-to-bottom order (baseline JPEG). // Accumulates columns into mcuBuf; once the last column arrives (completing the MCU // row), applies scaling + dithering and writes packed BMP rows to bmpOut. int bmpDrawCallback(JPEGDRAW* pDraw) { auto* ctx = reinterpret_cast(pDraw->pUser); if (!ctx || ctx->error) return 0; yieldDuringDecodeBlock(ctx); const uint8_t* pixels = reinterpret_cast(pDraw->pPixels); const int stride = pDraw->iWidth; const int validW = pDraw->iWidthUsed; const int blockH = pDraw->iHeight; const int blockX = pDraw->x; const int blockY = pDraw->y; // Guard against unexpected callback geometry so we never index past row buffers. if (blockX < 0 || blockY < 0 || blockX >= ctx->srcWidth || blockY >= ctx->srcHeight) { LOG_ERR("JPG", "Unexpected JPEG block origin (%d,%d) for decode grid %dx%d", blockX, blockY, ctx->srcWidth, ctx->srcHeight); ctx->error = true; return 0; } // Copy block pixels into MCU row buffer for (int r = 0; r < blockH && r < MAX_MCU_HEIGHT; r++) { const int copyW = (blockX + validW <= ctx->srcWidth) ? validW : (ctx->srcWidth - blockX); if (copyW <= 0) continue; memcpy(ctx->mcuBuf.get() + r * ctx->srcWidth + blockX, pixels + r * stride, copyW); } // Wait for the last MCU column before processing any rows if (blockX + validW < ctx->srcWidth) return 1; // Process each complete source row in this MCU row const int endRow = blockY + blockH; for (int y = blockY; y < endRow && y < ctx->srcHeight; y++) { const uint8_t* srcRow = ctx->mcuBuf.get() + (y - blockY) * ctx->srcWidth; if (ctx->smoothUpscale) { processSmoothSourceRow(ctx, srcRow, y); } else if (!ctx->needsScaling) { // 1:1 — outWidth == srcWidth, write directly writeOutputRow(ctx, srcRow, y); } else { // Fixed-point area averaging on X axis for (int outX = 0; outX < ctx->outWidth; outX++) { const int srcXStart = (static_cast(outX) * ctx->scaleX_fp) >> 16; const int srcXEnd = (static_cast(outX + 1) * ctx->scaleX_fp) >> 16; int sum = 0; int count = 0; for (int srcX = srcXStart; srcX < srcXEnd && srcX < ctx->srcWidth; srcX++) { sum += srcRow[srcX]; count++; } if (count == 0 && srcXStart < ctx->srcWidth) { sum = srcRow[srcXStart]; count = 1; } ctx->rowAccum[outX] += sum; ctx->rowCount[outX] += count; } // Flush output row(s) whose Y boundary we've crossed const uint32_t srcY_fp = static_cast(y + 1) << 16; while (srcY_fp >= ctx->nextOutY_srcStart && ctx->currentOutY < ctx->outHeight) { flushScaledRow(ctx); ctx->nextOutY_srcStart = static_cast(ctx->currentOutY + 1) * ctx->scaleY_fp; if (srcY_fp >= ctx->nextOutY_srcStart) continue; memset(ctx->rowAccum.get(), 0, ctx->outWidth * sizeof(uint32_t)); memset(ctx->rowCount.get(), 0, ctx->outWidth * sizeof(uint32_t)); } } } return ctx->error ? 0 : 1; } } // namespace // Internal implementation with configurable target size and bit depth bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit, bool crop) { LOG_DBG("JPG", "Converting JPEG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight); if (ESP.getFreeHeap() < MIN_FREE_HEAP) { LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", ESP.getFreeHeap(), MIN_FREE_HEAP); return false; } s_jpegFile = &jpegFile; const auto jpeg = makeUniqueNoThrow(); if (!jpeg) { LOG_ERR("JPG", "OOM: JPEG decoder"); return false; } int rc = jpeg->open("", bmpJpegOpen, bmpJpegClose, bmpJpegRead, bmpJpegSeek, bmpDrawCallback); if (rc != 1) { LOG_ERR("JPG", "JPEG open failed (err=%d)", jpeg->getLastError()); return false; } const ScopedCleanup cleanup{[&jpeg]() { jpeg->close(); }}; const int srcWidth = jpeg->getWidth(); const int srcHeight = jpeg->getHeight(); const bool progressiveDecode = (jpeg->getJPEGType() == JPEG_MODE_PROGRESSIVE); // JPEGDEC forces progressive streams to JPEG_SCALE_EIGHTH in DecodeJPEG, // so callback coordinates and MCU buffering must use the reduced decode grid. const int decodedSrcWidth = progressiveDecode ? ((srcWidth + 7) >> 3) : srcWidth; const int decodedSrcHeight = progressiveDecode ? ((srcHeight + 7) >> 3) : srcHeight; LOG_DBG("JPG", "JPEG dimensions: %dx%d", srcWidth, srcHeight); if (progressiveDecode) { LOG_DBG("JPG", "Progressive JPEG decode uses 1/8 source: %dx%d", decodedSrcWidth, decodedSrcHeight); } constexpr int MAX_IMAGE_WIDTH = 2048; constexpr int MAX_IMAGE_HEIGHT = 3072; if (srcWidth <= 0 || srcHeight <= 0 || srcWidth > MAX_IMAGE_WIDTH || srcHeight > MAX_IMAGE_HEIGHT) { LOG_DBG("JPG", "Image too large or invalid (%dx%d), max supported: %dx%d", srcWidth, srcHeight, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); return false; } // Calculate output dimensions (pre-scale to fit display exactly) int outWidth = srcWidth; int outHeight = srcHeight; if (targetWidth <= 0 || targetHeight <= 0) { // Without an explicit target, keep decoder-native dimensions. outWidth = decodedSrcWidth; outHeight = decodedSrcHeight; } const int scaleSrcWidth = decodedSrcWidth; const int scaleSrcHeight = decodedSrcHeight; uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point uint32_t scaleY_fp = 65536; bool needsScaling = false; if (targetWidth > 0 && targetHeight > 0 && (srcWidth != targetWidth || srcHeight != targetHeight)) { const float scaleToFitWidth = static_cast(targetWidth) / srcWidth; const float scaleToFitHeight = static_cast(targetHeight) / srcHeight; float scale = 1.0f; if (crop) { scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; } else { scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; } outWidth = static_cast(srcWidth * scale); outHeight = static_cast(srcHeight * scale); if (outWidth < 1) outWidth = 1; if (outHeight < 1) outHeight = 1; LOG_DBG("JPG", "Scaling source %dx%d (decode grid %dx%d) -> %dx%d (target %dx%d)", srcWidth, srcHeight, scaleSrcWidth, scaleSrcHeight, outWidth, outHeight, targetWidth, targetHeight); } if (scaleSrcWidth != outWidth || scaleSrcHeight != outHeight) { scaleX_fp = (static_cast(scaleSrcWidth) << 16) / outWidth; scaleY_fp = (static_cast(scaleSrcHeight) << 16) / outHeight; needsScaling = true; } const bool smoothUpscale = progressiveDecode && needsScaling && scaleSrcWidth <= outWidth && scaleSrcHeight <= outHeight; // Write BMP header with output dimensions int bytesPerRow; if (USE_8BIT_OUTPUT && !oneBit) { writeBmpHeader8bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth + 3) / 4 * 4; } else if (oneBit) { writeBmpHeader1bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth + 31) / 32 * 4; } else { writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; } BmpConvertCtx ctx = {}; ctx.bmpOut = &bmpOut; ctx.srcWidth = scaleSrcWidth; ctx.srcHeight = scaleSrcHeight; ctx.outWidth = outWidth; ctx.outHeight = outHeight; ctx.oneBit = oneBit; ctx.bytesPerRow = bytesPerRow; ctx.needsScaling = needsScaling; ctx.scaleX_fp = scaleX_fp; ctx.scaleY_fp = scaleY_fp; ctx.smoothUpscale = smoothUpscale; ctx.smoothScaleX_fp = interpolationStep(ctx.srcWidth, outWidth); ctx.smoothScaleY_fp = interpolationStep(ctx.srcHeight, outHeight); ctx.smoothNextOutY = 0; ctx.smoothPrevY = -1; ctx.rowsSinceYield = 0; ctx.blocksSinceYield = 0; ctx.error = false; // MCU row buffer: MAX_MCU_HEIGHT rows × decoded srcWidth columns of grayscale ctx.mcuBuf = makeUniqueNoThrow(MAX_MCU_HEIGHT * ctx.srcWidth); if (!ctx.mcuBuf) { LOG_ERR("JPG", "OOM: MCU buffer (%d bytes)", MAX_MCU_HEIGHT * ctx.srcWidth); return false; } memset(ctx.mcuBuf.get(), 0, MAX_MCU_HEIGHT * ctx.srcWidth); ctx.bmpRow = makeUniqueNoThrow(bytesPerRow); if (!ctx.bmpRow) { LOG_ERR("JPG", "OOM: BMP row buffer"); return false; } if (smoothUpscale) { // One contiguous allocation avoids three heap blocks while keeping smoothing line-buffered. const size_t smoothRowsBytes = static_cast(outWidth) * 3; ctx.smoothRows = makeUniqueNoThrow(smoothRowsBytes); if (!ctx.smoothRows) { LOG_ERR("JPG", "OOM: progressive smoothing buffers"); return false; } ctx.smoothPrevRow = ctx.smoothRows.get(); ctx.smoothCurrRow = ctx.smoothPrevRow + outWidth; ctx.smoothOutRow = ctx.smoothCurrRow + outWidth; LOG_DBG("JPG", "Progressive smoothing: %dx%d -> %dx%d, buffers=%u bytes", ctx.srcWidth, ctx.srcHeight, outWidth, outHeight, static_cast(smoothRowsBytes)); } else if (needsScaling) { ctx.rowAccum = makeUniqueNoThrow(outWidth); ctx.rowCount = makeUniqueNoThrow(outWidth); if (!ctx.rowAccum || !ctx.rowCount) { LOG_ERR("JPG", "OOM: scaling buffers"); return false; } ctx.nextOutY_srcStart = scaleY_fp; } if (oneBit) { ctx.atkinson1BitDitherer = makeUniqueNoThrow(outWidth); if (!ctx.atkinson1BitDitherer) { LOG_ERR("JPG", "OOM: Atkinson1BitDitherer"); return false; } } else if (!USE_8BIT_OUTPUT) { if (USE_ATKINSON) { ctx.atkinsonDitherer = makeUniqueNoThrow(outWidth); if (!ctx.atkinsonDitherer) { LOG_ERR("JPG", "OOM: AtkinsonDitherer"); return false; } } else if (USE_FLOYD_STEINBERG) { ctx.fsDitherer = makeUniqueNoThrow(outWidth); if (!ctx.fsDitherer) { LOG_ERR("JPG", "OOM: FloydSteinbergDitherer"); return false; } } } jpeg->setPixelType(EIGHT_BIT_GRAYSCALE); jpeg->setUserPointer(&ctx); rc = jpeg->decode(0, 0, 0); if (rc == 1 && ctx.smoothUpscale && !ctx.error) { finishSmoothUpscale(&ctx); } if (rc != 1 || ctx.error) { LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError()); return false; } LOG_DBG("JPG", "Successfully converted JPEG to BMP"); return true; } // Core function: Convert JPEG file to 2-bit BMP (uses default target size) bool JpegToBmpConverter::jpegFileToBmpStream(HalFile& jpegFile, Print& bmpOut, bool crop) { // Use runtime display dimensions (swapped for portrait cover sizing) const int targetWidth = display.getDisplayHeight(); const int targetHeight = display.getDisplayWidth(); return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetWidth, targetHeight, false, crop); } // Convert with custom target size (for thumbnails, 2-bit) bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(HalFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight) { return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false); } // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(HalFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight) { return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true, true); }