733 lines
26 KiB
C++
733 lines
26 KiB
C++
#include "JpegToFramebufferConverter.h"
|
|
|
|
#include <BitmapHelpers.h>
|
|
#include <FsHelpers.h>
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <JPEGDEC.h>
|
|
#include <Logging.h>
|
|
|
|
#include <cstdlib>
|
|
#include <limits>
|
|
#include <memory>
|
|
#include <new>
|
|
|
|
#include "DirectPixelWriter.h"
|
|
#include "DitherUtils.h"
|
|
#include "PixelCache.h"
|
|
|
|
namespace {
|
|
|
|
// Context struct passed through JPEGDEC callbacks to avoid global mutable state.
|
|
// The draw callback receives this via pDraw->pUser (set by setUserPointer()).
|
|
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by jpegOpen()).
|
|
struct JpegContext {
|
|
GfxRenderer* renderer{nullptr};
|
|
const RenderConfig* config{nullptr};
|
|
int screenWidth{0};
|
|
int screenHeight{0};
|
|
ImageDitherMode effectiveDitherMode{ImageDitherMode::Bayer};
|
|
|
|
// Source dimensions after JPEGDEC's built-in scaling
|
|
int scaledSrcWidth{0};
|
|
int scaledSrcHeight{0};
|
|
|
|
// Final output dimensions
|
|
int dstWidth{0};
|
|
int dstHeight{0};
|
|
|
|
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU).
|
|
// X and Y use separate scale factors because dstWidth/dstHeight may differ from
|
|
// scaledSrcWidth/scaledSrcHeight in aspect ratio (integer rounding of displayHeight),
|
|
// so a single X-derived factor would map Y rows incorrectly and crop visible content.
|
|
int32_t fineScaleFPX{1 << 16}; // src -> dst mapping (X axis)
|
|
int32_t invScaleFPX{1 << 16}; // dst -> src mapping (X axis)
|
|
int32_t fineScaleFPY{1 << 16}; // src -> dst mapping (Y axis)
|
|
int32_t invScaleFPY{1 << 16}; // dst -> src mapping (Y axis)
|
|
|
|
PixelCache cache;
|
|
bool caching{false};
|
|
|
|
// See PngContext for the rationale: monochromeOutput requests a 1-bit Atkinson dither
|
|
// emitting only 0/3 so the BW DirectPixelWriter (`pixelValue < 3` rule) maps cleanly.
|
|
int oneBitDitherRow{-1};
|
|
std::unique_ptr<Atkinson1BitDitherer> atkinson1BitDitherer;
|
|
|
|
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
|
int currentDitherRow{-1};
|
|
std::unique_ptr<AtkinsonDitherer> atkinsonDitherer;
|
|
std::unique_ptr<DiffusedBayerDitherer> diffusedBayerDitherer;
|
|
#endif
|
|
};
|
|
|
|
// Advance the 1-bit Atkinson ditherer to the requested destination row.
|
|
// Handles non-monotonic row walks (block-based JPEG decode) by reset+replay.
|
|
void prepareOneBitDitherRow(JpegContext& ctx, int dstY) {
|
|
if (!ctx.atkinson1BitDitherer) return;
|
|
|
|
if (ctx.oneBitDitherRow == -1 || dstY < ctx.oneBitDitherRow) {
|
|
ctx.atkinson1BitDitherer->reset();
|
|
ctx.oneBitDitherRow = dstY;
|
|
return;
|
|
}
|
|
|
|
while (ctx.oneBitDitherRow < dstY) {
|
|
ctx.atkinson1BitDitherer->nextRow();
|
|
ctx.oneBitDitherRow++;
|
|
}
|
|
}
|
|
|
|
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
|
void prepareDitherRow(JpegContext& ctx, int dstY) {
|
|
if (!ctx.config || !ctx.config->useDithering) return;
|
|
|
|
if (ctx.currentDitherRow == -1 || dstY < ctx.currentDitherRow) {
|
|
if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->reset();
|
|
if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->reset();
|
|
ctx.currentDitherRow = dstY;
|
|
return;
|
|
}
|
|
|
|
while (ctx.currentDitherRow < dstY) {
|
|
if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->nextRow();
|
|
if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->nextRow();
|
|
ctx.currentDitherRow++;
|
|
}
|
|
}
|
|
|
|
uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
|
if (ctx.atkinson1BitDitherer) {
|
|
return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0;
|
|
}
|
|
|
|
if (!ctx.config || !ctx.config->useDithering) {
|
|
return quantizeGray4Level(gray);
|
|
}
|
|
|
|
switch (ctx.effectiveDitherMode) {
|
|
case ImageDitherMode::Atkinson:
|
|
if (ctx.atkinsonDitherer) {
|
|
return ctx.atkinsonDitherer->processPixel(gray, localX);
|
|
}
|
|
break;
|
|
case ImageDitherMode::DiffusedBayer:
|
|
if (ctx.diffusedBayerDitherer) {
|
|
return ctx.diffusedBayerDitherer->processPixel(gray, localX, outX, outY);
|
|
}
|
|
break;
|
|
case ImageDitherMode::Bayer:
|
|
case ImageDitherMode::COUNT:
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return applyBayerDither4Level(gray, outX, outY);
|
|
}
|
|
#else
|
|
uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
|
if (ctx.atkinson1BitDitherer) {
|
|
return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0;
|
|
}
|
|
(void)localX;
|
|
return applyBayerDither4Level(gray, outX, outY);
|
|
}
|
|
#endif
|
|
|
|
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
|
// avoiding the need for global file state.
|
|
void* jpegOpen(const char* filename, int32_t* size) {
|
|
FsFile* f =
|
|
new FsFile(); // NOLINT(cppcoreguidelines-owning-memory) — ownership transferred via void* to JPEGDEC callbacks
|
|
if (!Storage.openFileForRead("JPG", std::string(filename), *f)) {
|
|
delete f; // NOLINT(cppcoreguidelines-owning-memory)
|
|
return nullptr;
|
|
}
|
|
*size = f->size();
|
|
return f;
|
|
}
|
|
|
|
void jpegClose(void* handle) {
|
|
FsFile* f = reinterpret_cast<FsFile*>(handle);
|
|
if (f) {
|
|
f->close();
|
|
delete f; // NOLINT(cppcoreguidelines-owning-memory)
|
|
}
|
|
}
|
|
|
|
// JPEGDEC tracks file position via pFile->iPos internally (e.g. JPEGGetMoreData
|
|
// checks iPos < iSize to decide whether more data is available). The callbacks
|
|
// MUST maintain iPos to match the actual file position, otherwise progressive
|
|
// JPEGs with large headers fail during parsing.
|
|
int32_t jpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
|
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
|
if (!f) return 0;
|
|
int32_t bytesRead = f->read(pBuf, len);
|
|
if (bytesRead < 0) return 0;
|
|
pFile->iPos += bytesRead;
|
|
return bytesRead;
|
|
}
|
|
|
|
int32_t jpegSeek(JPEGFILE* pFile, int32_t pos) {
|
|
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
|
if (!f) return -1;
|
|
if (!f->seek(pos)) return -1;
|
|
pFile->iPos = pos;
|
|
return pos;
|
|
}
|
|
|
|
// JPEGDEC object is ~17 KB due to internal decode buffers.
|
|
// Heap-allocate on demand so memory is only used during active decode.
|
|
constexpr size_t JPEG_DECODER_APPROX_SIZE = 20 * 1024;
|
|
constexpr size_t MIN_FREE_HEAP_FOR_JPEG = JPEG_DECODER_APPROX_SIZE + 16 * 1024;
|
|
|
|
// Optional memory-behavior knobs for embedded targets.
|
|
#ifndef JPEG_ENABLE_FIRST_RENDER_NO_CACHE
|
|
#define JPEG_ENABLE_FIRST_RENDER_NO_CACHE 1
|
|
#endif
|
|
|
|
#ifndef JPEG_CACHE_MIN_FREE_HEAP_MARGIN
|
|
#define JPEG_CACHE_MIN_FREE_HEAP_MARGIN (24 * 1024)
|
|
#endif
|
|
|
|
#ifndef JPEG_DITHER_LOW_MEM_MIN_FREE_HEAP
|
|
#define JPEG_DITHER_LOW_MEM_MIN_FREE_HEAP (MIN_FREE_HEAP_FOR_JPEG + 8 * 1024)
|
|
#endif
|
|
|
|
#ifndef JPEG_DITHER_LOW_MEM_MIN_MAX_ALLOC
|
|
#define JPEG_DITHER_LOW_MEM_MIN_MAX_ALLOC (48 * 1024)
|
|
#endif
|
|
|
|
size_t jpegCacheBytes(int width, int height) {
|
|
return static_cast<size_t>((width + 3) / 4) * static_cast<size_t>(height);
|
|
}
|
|
|
|
bool shouldEnableJpegCache(const RenderConfig& config, int width, int height) {
|
|
if (config.cachePath.empty()) return false;
|
|
|
|
#if JPEG_ENABLE_FIRST_RENDER_NO_CACHE
|
|
if (!Storage.exists(config.cachePath.c_str())) {
|
|
LOG_DBG("JPG", "No existing JPEG cache file on first render; enabling cache write: %s", config.cachePath.c_str());
|
|
}
|
|
#endif
|
|
|
|
const size_t cacheBytes = jpegCacheBytes(width, height);
|
|
const size_t freeHeap = ESP.getFreeHeap();
|
|
const size_t minFreeForCaching = MIN_FREE_HEAP_FOR_JPEG + JPEG_CACHE_MIN_FREE_HEAP_MARGIN;
|
|
|
|
if (freeHeap < minFreeForCaching) {
|
|
LOG_DBG("JPG", "Skipping cache: free heap %u < %u (cache %u bytes)", static_cast<unsigned>(freeHeap),
|
|
static_cast<unsigned>(minFreeForCaching), static_cast<unsigned>(cacheBytes));
|
|
return false;
|
|
}
|
|
|
|
// Don't pre-check maxAlloc: the JPEG decoder (~20 KB) is already allocated here so
|
|
// maxAlloc already reflects that. Let allocate() attempt malloc and fail gracefully
|
|
// rather than refusing on a conservative margin that double-counts live allocations.
|
|
return true;
|
|
}
|
|
|
|
bool shouldForceBayerDither(const RenderConfig& config) {
|
|
if (!config.useDithering) return false;
|
|
if (config.ditherMode == ImageDitherMode::Bayer) return false;
|
|
|
|
const size_t freeHeap = ESP.getFreeHeap();
|
|
const size_t maxAlloc = ESP.getMaxAllocHeap();
|
|
return freeHeap < JPEG_DITHER_LOW_MEM_MIN_FREE_HEAP || maxAlloc < JPEG_DITHER_LOW_MEM_MIN_MAX_ALLOC;
|
|
}
|
|
|
|
bool readJpegDimensionsFromHeader(const std::string& imagePath, ImageDimensions& out) {
|
|
FsFile f;
|
|
if (!Storage.openFileForRead("JPG", imagePath, f)) {
|
|
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
|
|
return false;
|
|
}
|
|
|
|
auto readByte = [&f](uint8_t& b) -> bool { return f.read(&b, 1) == 1; };
|
|
auto readU16BE = [&f](uint16_t& v) -> bool {
|
|
uint8_t b[2];
|
|
if (f.read(b, 2) != 2) return false;
|
|
v = static_cast<uint16_t>((static_cast<uint16_t>(b[0]) << 8) | b[1]);
|
|
return true;
|
|
};
|
|
|
|
uint8_t b0 = 0;
|
|
uint8_t b1 = 0;
|
|
if (!readByte(b0) || !readByte(b1) || b0 != 0xFF || b1 != 0xD8) {
|
|
f.close();
|
|
LOG_ERR("JPG", "Not a JPEG file: %s", imagePath.c_str());
|
|
return false;
|
|
}
|
|
|
|
while (f.available()) {
|
|
uint8_t prefix = 0;
|
|
if (!readByte(prefix)) break;
|
|
if (prefix != 0xFF) continue;
|
|
|
|
uint8_t marker = 0;
|
|
do {
|
|
if (!readByte(marker)) {
|
|
f.close();
|
|
return false;
|
|
}
|
|
} while (marker == 0xFF);
|
|
|
|
if (marker == 0x00 || marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7)) {
|
|
continue;
|
|
}
|
|
|
|
uint16_t segLen = 0;
|
|
if (!readU16BE(segLen) || segLen < 2) {
|
|
f.close();
|
|
return false;
|
|
}
|
|
|
|
const bool isSof = (marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) ||
|
|
(marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF);
|
|
if (isSof) {
|
|
uint8_t sof[5];
|
|
if (segLen < 7 || f.read(sof, sizeof(sof)) != static_cast<int>(sizeof(sof))) {
|
|
f.close();
|
|
return false;
|
|
}
|
|
uint16_t height = static_cast<uint16_t>((static_cast<uint16_t>(sof[1]) << 8) | sof[2]);
|
|
uint16_t width = static_cast<uint16_t>((static_cast<uint16_t>(sof[3]) << 8) | sof[4]);
|
|
f.close();
|
|
if (width == 0 || height == 0) {
|
|
LOG_ERR("JPG", "Invalid JPEG dimensions %ux%u: %s", width, height, imagePath.c_str());
|
|
return false;
|
|
}
|
|
|
|
if (width > static_cast<uint16_t>(std::numeric_limits<int16_t>::max()) ||
|
|
height > static_cast<uint16_t>(std::numeric_limits<int16_t>::max())) {
|
|
LOG_ERR("JPG", "JPEG dimensions out of supported range %ux%u: %s", width, height, imagePath.c_str());
|
|
return false;
|
|
}
|
|
|
|
out.width = static_cast<int16_t>(width);
|
|
out.height = static_cast<int16_t>(height);
|
|
return true;
|
|
}
|
|
|
|
const int32_t skip = static_cast<int32_t>(segLen) - 2;
|
|
if (!f.seek(f.position() + skip)) {
|
|
f.close();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
f.close();
|
|
LOG_ERR("JPG", "No SOF marker found for dimensions: %s", imagePath.c_str());
|
|
return false;
|
|
}
|
|
|
|
// Choose JPEGDEC's built-in scale factor for coarse downscaling.
|
|
// Returns the scale denominator (1, 2, 4, or 8) and sets jpegScaleOption.
|
|
int chooseJpegScale(float targetScale, int& jpegScaleOption) {
|
|
if (targetScale <= 0.125f) {
|
|
jpegScaleOption = JPEG_SCALE_EIGHTH;
|
|
return 8;
|
|
}
|
|
if (targetScale <= 0.25f) {
|
|
jpegScaleOption = JPEG_SCALE_QUARTER;
|
|
return 4;
|
|
}
|
|
if (targetScale <= 0.5f) {
|
|
jpegScaleOption = JPEG_SCALE_HALF;
|
|
return 2;
|
|
}
|
|
jpegScaleOption = 0;
|
|
return 1;
|
|
}
|
|
|
|
// Fixed-point 16.16 arithmetic avoids software float emulation on ESP32-C3 (no FPU).
|
|
constexpr int FP_SHIFT = 16;
|
|
constexpr int32_t FP_ONE = 1 << FP_SHIFT;
|
|
constexpr int32_t FP_MASK = FP_ONE - 1;
|
|
|
|
int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|
JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser);
|
|
if (!ctx || !ctx->config || !ctx->renderer) return 0;
|
|
|
|
// In EIGHT_BIT_GRAYSCALE mode, pPixels contains 8-bit grayscale values
|
|
// Buffer is densely packed: stride = pDraw->iWidth, valid columns = pDraw->iWidthUsed
|
|
uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
|
|
const int stride = pDraw->iWidth;
|
|
const int validW = pDraw->iWidthUsed;
|
|
const int blockH = pDraw->iHeight;
|
|
|
|
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
|
|
|
|
const bool caching = ctx->caching;
|
|
const int32_t fineScaleFPX = ctx->fineScaleFPX;
|
|
const int32_t invScaleFPX = ctx->invScaleFPX;
|
|
const int32_t fineScaleFPY = ctx->fineScaleFPY;
|
|
const int32_t invScaleFPY = ctx->invScaleFPY;
|
|
GfxRenderer& renderer = *ctx->renderer;
|
|
const int cfgX = ctx->config->x;
|
|
const int cfgY = ctx->config->y;
|
|
const int blockX = pDraw->x;
|
|
const int blockY = pDraw->y;
|
|
|
|
// Determine destination pixel range covered by this source block
|
|
const int srcYEnd = blockY + blockH;
|
|
const int srcXEnd = blockX + validW;
|
|
|
|
int dstYStart = (int)((int64_t)blockY * fineScaleFPY >> FP_SHIFT);
|
|
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFPY >> FP_SHIFT);
|
|
int dstXStart = (int)((int64_t)blockX * fineScaleFPX >> FP_SHIFT);
|
|
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFPX >> FP_SHIFT);
|
|
|
|
// Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks)
|
|
int clampYMax = ctx->dstHeight;
|
|
if (ctx->screenHeight - cfgY < clampYMax) clampYMax = ctx->screenHeight - cfgY;
|
|
if (dstYStart < -cfgY) dstYStart = -cfgY;
|
|
if (dstYEnd > clampYMax) dstYEnd = clampYMax;
|
|
|
|
int clampXMax = ctx->dstWidth;
|
|
if (ctx->screenWidth - cfgX < clampXMax) clampXMax = ctx->screenWidth - cfgX;
|
|
if (dstXStart < -cfgX) dstXStart = -cfgX;
|
|
if (dstXEnd > clampXMax) dstXEnd = clampXMax;
|
|
|
|
if (dstYStart >= dstYEnd || dstXStart >= dstXEnd) return 1;
|
|
|
|
// Pre-compute orientation and render-mode state once per callback invocation
|
|
DirectPixelWriter pw;
|
|
pw.init(renderer);
|
|
|
|
DirectCacheWriter cw;
|
|
if (caching) {
|
|
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
|
|
}
|
|
|
|
// === 1:1 fast path: no scaling math ===
|
|
if (fineScaleFPX == FP_ONE && fineScaleFPY == FP_ONE) {
|
|
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
|
const int outY = cfgY + dstY;
|
|
prepareOneBitDitherRow(*ctx, dstY);
|
|
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
|
prepareDitherRow(*ctx, dstY);
|
|
#endif
|
|
pw.beginRow(outY);
|
|
if (caching) cw.beginRow(outY, ctx->config->y);
|
|
const uint8_t* row = &pixels[(dstY - blockY) * stride];
|
|
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
|
|
const int outX = cfgX + dstX;
|
|
uint8_t gray = row[dstX - blockX];
|
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
|
pw.writePixel(outX, dithered);
|
|
if (caching) cw.writePixel(outX, dithered);
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// === Bilinear interpolation (upscale: fineScale > 1.0) ===
|
|
// Smooths block boundaries that would otherwise create visible banding
|
|
// on progressive JPEG DC-only decode (1/8 resolution upscaled to target).
|
|
if (fineScaleFPX > FP_ONE && fineScaleFPY > FP_ONE) {
|
|
// Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1].
|
|
// Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping.
|
|
int safeXStart = (int)(((int64_t)blockX * fineScaleFPX + FP_MASK) >> FP_SHIFT);
|
|
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFPX >> FP_SHIFT);
|
|
if (safeXStart < dstXStart) safeXStart = dstXStart;
|
|
if (safeXEnd > dstXEnd) safeXEnd = dstXEnd;
|
|
if (safeXStart > safeXEnd) safeXEnd = safeXStart;
|
|
|
|
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
|
const int outY = cfgY + dstY;
|
|
prepareOneBitDitherRow(*ctx, dstY);
|
|
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
|
prepareDitherRow(*ctx, dstY);
|
|
#endif
|
|
pw.beginRow(outY);
|
|
if (caching) cw.beginRow(outY, ctx->config->y);
|
|
const int32_t srcFyFP = dstY * invScaleFPY;
|
|
const int32_t fy = srcFyFP & FP_MASK;
|
|
const int32_t fyInv = FP_ONE - fy;
|
|
int ly0 = (srcFyFP >> FP_SHIFT) - blockY;
|
|
int ly1 = ly0 + 1;
|
|
if (ly0 < 0) ly0 = 0;
|
|
if (ly0 >= blockH) ly0 = blockH - 1;
|
|
if (ly1 >= blockH) ly1 = blockH - 1;
|
|
|
|
const uint8_t* row0 = &pixels[ly0 * stride];
|
|
const uint8_t* row1 = &pixels[ly1 * stride];
|
|
|
|
// Left edge (with X boundary clamping)
|
|
for (int dstX = dstXStart; dstX < safeXStart; dstX++) {
|
|
const int outX = cfgX + dstX;
|
|
const int32_t srcFxFP = dstX * invScaleFPX;
|
|
const int32_t fx = srcFxFP & FP_MASK;
|
|
const int32_t fxInv = FP_ONE - fx;
|
|
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
|
int lx1 = lx0 + 1;
|
|
if (lx0 < 0) lx0 = 0;
|
|
if (lx1 < 0) lx1 = 0;
|
|
if (lx0 >= validW) lx0 = validW - 1;
|
|
if (lx1 >= validW) lx1 = validW - 1;
|
|
|
|
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
|
|
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
|
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
|
|
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
|
pw.writePixel(outX, dithered);
|
|
if (caching) cw.writePixel(outX, dithered);
|
|
}
|
|
|
|
// Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds)
|
|
for (int dstX = safeXStart; dstX < safeXEnd; dstX++) {
|
|
const int outX = cfgX + dstX;
|
|
const int32_t srcFxFP = dstX * invScaleFPX;
|
|
const int32_t fx = srcFxFP & FP_MASK;
|
|
const int32_t fxInv = FP_ONE - fx;
|
|
const int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
|
|
|
int top = ((int)row0[lx0] * fxInv + (int)row0[lx0 + 1] * fx) >> FP_SHIFT;
|
|
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT;
|
|
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
|
|
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
|
pw.writePixel(outX, dithered);
|
|
if (caching) cw.writePixel(outX, dithered);
|
|
}
|
|
|
|
// Right edge (with X boundary clamping)
|
|
for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) {
|
|
const int outX = cfgX + dstX;
|
|
const int32_t srcFxFP = dstX * invScaleFPX;
|
|
const int32_t fx = srcFxFP & FP_MASK;
|
|
const int32_t fxInv = FP_ONE - fx;
|
|
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
|
int lx1 = lx0 + 1;
|
|
if (lx0 >= validW) lx0 = validW - 1;
|
|
if (lx1 >= validW) lx1 = validW - 1;
|
|
|
|
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
|
|
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
|
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
|
|
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
|
pw.writePixel(outX, dithered);
|
|
if (caching) cw.writePixel(outX, dithered);
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
|
|
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
|
const int outY = cfgY + dstY;
|
|
prepareOneBitDitherRow(*ctx, dstY);
|
|
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
|
prepareDitherRow(*ctx, dstY);
|
|
#endif
|
|
pw.beginRow(outY);
|
|
if (caching) cw.beginRow(outY, ctx->config->y);
|
|
const int32_t srcFyFP = dstY * invScaleFPY;
|
|
int ly = (srcFyFP >> FP_SHIFT) - blockY;
|
|
if (ly < 0) ly = 0;
|
|
if (ly >= blockH) ly = blockH - 1;
|
|
const uint8_t* row = &pixels[ly * stride];
|
|
|
|
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
|
|
const int outX = cfgX + dstX;
|
|
const int32_t srcFxFP = dstX * invScaleFPX;
|
|
int lx = (srcFxFP >> FP_SHIFT) - blockX;
|
|
if (lx < 0) lx = 0;
|
|
if (lx >= validW) lx = validW - 1;
|
|
uint8_t gray = row[lx];
|
|
|
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
|
pw.writePixel(outX, dithered);
|
|
if (caching) cw.writePixel(outX, dithered);
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
|
if (!readJpegDimensionsFromHeader(imagePath, out)) {
|
|
return false;
|
|
}
|
|
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
|
|
return true;
|
|
}
|
|
|
|
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
|
const RenderConfig& config) {
|
|
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
|
|
|
|
size_t freeHeap = ESP.getFreeHeap();
|
|
if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) {
|
|
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG);
|
|
return false;
|
|
}
|
|
|
|
std::unique_ptr<JPEGDEC> jpeg(new (std::nothrow) JPEGDEC());
|
|
if (!jpeg) {
|
|
LOG_ERR("JPG", "Failed to allocate JPEG decoder");
|
|
return false;
|
|
}
|
|
|
|
JpegContext ctx;
|
|
ctx.renderer = &renderer;
|
|
ctx.config = &config;
|
|
ctx.screenWidth = renderer.getScreenWidth();
|
|
ctx.screenHeight = renderer.getScreenHeight();
|
|
ctx.effectiveDitherMode = config.ditherMode;
|
|
|
|
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, jpegDrawCallback);
|
|
if (rc != 1) {
|
|
LOG_ERR("JPG", "Failed to open JPEG (err=%d): %s", jpeg->getLastError(), imagePath.c_str());
|
|
return false;
|
|
}
|
|
|
|
int srcWidth = jpeg->getWidth();
|
|
int srcHeight = jpeg->getHeight();
|
|
|
|
if (srcWidth <= 0 || srcHeight <= 0) {
|
|
LOG_ERR("JPG", "Invalid JPEG dimensions: %dx%d", srcWidth, srcHeight);
|
|
jpeg->close();
|
|
return false;
|
|
}
|
|
|
|
bool isProgressive = jpeg->getJPEGType() == JPEG_MODE_PROGRESSIVE;
|
|
if (isProgressive) {
|
|
LOG_INF("JPG", "Progressive JPEG detected - decoding DC coefficients only (lower quality)");
|
|
}
|
|
|
|
// Calculate overall target scale
|
|
float targetScale;
|
|
int destWidth, destHeight;
|
|
|
|
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
|
destWidth = config.maxWidth;
|
|
destHeight = config.maxHeight;
|
|
targetScale = (float)destWidth / srcWidth;
|
|
} else {
|
|
float scaleX = (config.maxWidth > 0 && srcWidth > config.maxWidth) ? (float)config.maxWidth / srcWidth : 1.0f;
|
|
float scaleY = (config.maxHeight > 0 && srcHeight > config.maxHeight) ? (float)config.maxHeight / srcHeight : 1.0f;
|
|
targetScale = (scaleX < scaleY) ? scaleX : scaleY;
|
|
if (targetScale > 1.0f) targetScale = 1.0f;
|
|
|
|
destWidth = (int)(srcWidth * targetScale);
|
|
destHeight = (int)(srcHeight * targetScale);
|
|
}
|
|
|
|
// Choose JPEGDEC built-in scaling for coarse downscaling.
|
|
// Progressive JPEGs: JPEGDEC forces JPEG_SCALE_EIGHTH internally (DC-only
|
|
// decode produces 1/8 resolution). We must match this to avoid the if/else
|
|
// priority chain in DecodeJPEG selecting a different scale.
|
|
int jpegScaleOption;
|
|
int jpegScaleDenom;
|
|
if (isProgressive) {
|
|
jpegScaleOption = JPEG_SCALE_EIGHTH;
|
|
jpegScaleDenom = 8;
|
|
} else {
|
|
jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption);
|
|
}
|
|
|
|
ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom;
|
|
ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom;
|
|
|
|
// Validate memory footprint against the post-scaling decode size, not raw dimensions.
|
|
// A 1447x2200 image decoded at 1/4 scale is only ~362x550 — well within limits.
|
|
if (!validateImageDimensions(ctx.scaledSrcWidth, ctx.scaledSrcHeight, "JPEG")) {
|
|
jpeg->close();
|
|
return false;
|
|
}
|
|
ctx.dstWidth = destWidth;
|
|
ctx.dstHeight = destHeight;
|
|
if (destWidth <= 0 || destHeight <= 0) {
|
|
LOG_ERR("JPG", "Zero-sized output (%dx%d), aborting", destWidth, destHeight);
|
|
return false;
|
|
}
|
|
ctx.fineScaleFPX = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
|
|
ctx.invScaleFPX = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
|
|
ctx.fineScaleFPY = (int32_t)((int64_t)destHeight * FP_ONE / ctx.scaledSrcHeight);
|
|
ctx.invScaleFPY = (int32_t)((int64_t)ctx.scaledSrcHeight * FP_ONE / destHeight);
|
|
|
|
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth,
|
|
destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth,
|
|
isProgressive ? " [progressive]" : "");
|
|
|
|
// Set pixel type to 8-bit grayscale (must be after open())
|
|
jpeg->setPixelType(EIGHT_BIT_GRAYSCALE);
|
|
jpeg->setUserPointer(&ctx);
|
|
|
|
// Allocate cache buffer using final output dimensions
|
|
ctx.caching = shouldEnableJpegCache(config, destWidth, destHeight);
|
|
if (ctx.caching) {
|
|
if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) {
|
|
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
|
|
ctx.caching = false;
|
|
}
|
|
}
|
|
|
|
if (shouldForceBayerDither(config)) {
|
|
LOG_DBG("JPG", "Low-memory mode: forcing Bayer dithering (%u free, %u max alloc)",
|
|
static_cast<unsigned>(ESP.getFreeHeap()), static_cast<unsigned>(ESP.getMaxAllocHeap()));
|
|
ctx.effectiveDitherMode = ImageDitherMode::Bayer;
|
|
}
|
|
|
|
// See PngToFramebufferConverter for rationale: BW-only display needs a 1-bit
|
|
// dither so mid-grays don't collapse to black under DirectPixelWriter's `< 3` rule.
|
|
if (config.monochromeOutput) {
|
|
ctx.atkinson1BitDitherer.reset(new (std::nothrow) Atkinson1BitDitherer(destWidth));
|
|
if (!ctx.atkinson1BitDitherer) {
|
|
LOG_ERR("JPG", "Failed to allocate 1-bit Atkinson ditherer, falling back to 4-level dither");
|
|
}
|
|
}
|
|
|
|
if (config.useDithering && !ctx.atkinson1BitDitherer) {
|
|
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
|
switch (ctx.effectiveDitherMode) {
|
|
case ImageDitherMode::Atkinson:
|
|
ctx.atkinsonDitherer.reset(new (std::nothrow) AtkinsonDitherer(destWidth));
|
|
if (!ctx.atkinsonDitherer) {
|
|
LOG_ERR("JPG", "Failed to allocate Atkinson ditherer, falling back to Bayer");
|
|
}
|
|
break;
|
|
case ImageDitherMode::DiffusedBayer:
|
|
ctx.diffusedBayerDitherer.reset(new (std::nothrow) DiffusedBayerDitherer(destWidth));
|
|
if (!ctx.diffusedBayerDitherer) {
|
|
LOG_ERR("JPG", "Failed to allocate diffused Bayer ditherer, falling back to Bayer");
|
|
}
|
|
break;
|
|
case ImageDitherMode::Bayer:
|
|
case ImageDitherMode::COUNT:
|
|
default:
|
|
break;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
unsigned long decodeStart = millis();
|
|
rc = jpeg->decode(0, 0, jpegScaleOption);
|
|
unsigned long decodeTime = millis() - decodeStart;
|
|
|
|
if (rc != 1) {
|
|
LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError());
|
|
jpeg->close();
|
|
return false;
|
|
}
|
|
|
|
jpeg->close();
|
|
LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime);
|
|
|
|
// Write cache file if caching was enabled
|
|
if (ctx.caching) {
|
|
ctx.cache.writeToFile(config.cachePath);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
|
|
return FsHelpers::hasJpgExtension(extension);
|
|
}
|