Compare commits

...

2 Commits

Author SHA1 Message Date
Martin Brook
9b40d1cb32 perf: optimize PNG decoder with line-based conversion and integer scaling
Two optimizations that provide ~2.4x speedup:

1. Line-based grayscale conversion: process entire source row sequentially
   before sampling, improving cache locality and reducing function call
   overhead

2. Bresenham-style integer stepping: replace per-pixel floating-point
   division with integer accumulator for nearest-neighbor scaling

Benchmark results (ESP32-C3, 8-bit indexed PNGs):
- Scale 1.0 images: ~2.7x faster
- Scaled images: ~1.9x faster
- Total render time: 4060ms -> 1705ms
2026-02-03 18:54:41 +00:00
Martin Brook
4b1b4fb6b3 fix: handle PNG alpha channel by blending with white background
Transparent pixels in PNGs were being rendered incorrectly because alpha
was ignored. This fix alpha-blends all pixel types with white (255) for
proper e-ink display:
- Indexed PNGs with tRNS chunk (alpha at palette[768+index])
- RGBA PNGs (alpha from 4th byte)
- Gray+Alpha PNGs (alpha from 2nd byte)
2026-02-03 15:30:46 +00:00

View File

@ -95,46 +95,76 @@ bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath
return true; return true;
} }
// Helper to get grayscale from PNG pixel data // Convert entire source line to grayscale with alpha blending to white background.
static uint8_t getGrayFromPixel(uint8_t* pPixels, int x, int pixelType, uint8_t* palette) { // For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
static void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette,
int hasAlpha) {
switch (pixelType) { switch (pixelType) {
case PNG_PIXEL_GRAYSCALE: case PNG_PIXEL_GRAYSCALE:
return pPixels[x]; memcpy(grayLine, pPixels, width);
break;
case PNG_PIXEL_TRUECOLOR: { case PNG_PIXEL_TRUECOLOR:
uint8_t* p = &pPixels[x * 3]; for (int x = 0; x < width; x++) {
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); uint8_t* p = &pPixels[x * 3];
} grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
case PNG_PIXEL_INDEXED: {
uint8_t paletteIndex = pPixels[x];
if (palette) {
uint8_t* p = &palette[paletteIndex * 3];
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
} }
return paletteIndex; break;
}
case PNG_PIXEL_INDEXED:
if (palette) {
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = pPixels[x];
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
} else {
for (int x = 0; x < width; x++) {
uint8_t* p = &palette[pPixels[x] * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
}
} else {
memcpy(grayLine, pPixels, width);
}
break;
case PNG_PIXEL_GRAY_ALPHA: case PNG_PIXEL_GRAY_ALPHA:
return pPixels[x * 2]; for (int x = 0; x < width; x++) {
uint8_t gray = pPixels[x * 2];
uint8_t alpha = pPixels[x * 2 + 1];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
case PNG_PIXEL_TRUECOLOR_ALPHA: { case PNG_PIXEL_TRUECOLOR_ALPHA:
uint8_t* p = &pPixels[x * 4]; for (int x = 0; x < width; x++) {
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); uint8_t* p = &pPixels[x * 4];
} uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
default: default:
return 128; memset(grayLine, 128, width);
break;
} }
} }
// Stack buffer for grayscale line conversion (max width from PNGdec)
static uint8_t grayLineBuffer[PNG_MAX_BUFFERED_PIXELS / 2];
int pngDrawCallback(PNGDRAW* pDraw) { int pngDrawCallback(PNGDRAW* pDraw) {
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser); PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer) return 0; if (!ctx || !ctx->config || !ctx->renderer) return 0;
int srcY = pDraw->y; int srcY = pDraw->y;
uint8_t* pPixels = pDraw->pPixels; int srcWidth = ctx->srcWidth;
int pixelType = pDraw->iPixelType;
// Calculate destination Y with scaling // Calculate destination Y with scaling
int dstY = (int)(srcY * ctx->scale); int dstY = (int)(srcY * ctx->scale);
@ -149,26 +179,41 @@ int pngDrawCallback(PNGDRAW* pDraw) {
int outY = ctx->config->y + dstY; int outY = ctx->config->y + dstY;
if (outY >= ctx->screenHeight) return 1; if (outY >= ctx->screenHeight) return 1;
// Render scaled row using nearest-neighbor sampling // Convert entire source line to grayscale (improves cache locality)
for (int dstX = 0; dstX < ctx->dstWidth; dstX++) { convertLineToGray(pDraw->pPixels, grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette, pDraw->iHasAlpha);
int outX = ctx->config->x + dstX;
if (outX >= ctx->screenWidth) continue;
// Map destination X back to source X // Render scaled row using Bresenham-style integer stepping (no floating-point division)
int srcX = (int)(dstX / ctx->scale); int dstWidth = ctx->dstWidth;
if (srcX >= ctx->srcWidth) srcX = ctx->srcWidth - 1; int outXBase = ctx->config->x;
int screenWidth = ctx->screenWidth;
bool useDithering = ctx->config->useDithering;
bool caching = ctx->caching;
uint8_t gray = getGrayFromPixel(pPixels, srcX, pixelType, pDraw->pPalette); int srcX = 0;
int error = 0;
uint8_t ditheredGray; for (int dstX = 0; dstX < dstWidth; dstX++) {
if (ctx->config->useDithering) { int outX = outXBase + dstX;
ditheredGray = applyBayerDither4Level(gray, outX, outY); if (outX < screenWidth) {
} else { uint8_t gray = grayLineBuffer[srcX];
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3; uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
if (caching) ctx->cache.setPixel(outX, outY, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
} }
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
if (ctx->caching) ctx->cache.setPixel(outX, outY, ditheredGray);
} }
return 1; return 1;
@ -215,10 +260,6 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
warnUnsupportedFeature("bit depth (" + std::to_string(png.getBpp()) + "bpp)", imagePath); warnUnsupportedFeature("bit depth (" + std::to_string(png.getBpp()) + "bpp)", imagePath);
} }
if (png.hasAlpha()) {
warnUnsupportedFeature("alpha channel", imagePath);
}
// Allocate cache buffer using SCALED dimensions // Allocate cache buffer using SCALED dimensions
ctx.caching = !config.cachePath.empty(); ctx.caching = !config.cachePath.empty();
if (ctx.caching) { if (ctx.caching) {
@ -228,7 +269,9 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
} }
} }
unsigned long decodeStart = millis();
rc = png.decode(&ctx, 0); rc = png.decode(&ctx, 0);
unsigned long decodeTime = millis() - decodeStart;
if (rc != PNG_SUCCESS) { if (rc != PNG_SUCCESS) {
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc); Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
png.close(); png.close();
@ -236,7 +279,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
} }
png.close(); png.close();
Serial.printf("[%lu] [PNG] PNG decoding complete\n", millis()); Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime);
// Write cache file if caching was enabled and buffer was allocated // Write cache file if caching was enabled and buffer was allocated
if (ctx.caching) { if (ctx.caching) {