Add dithering algorithms

This commit is contained in:
jpirnay
2026-04-06 13:00:27 +02:00
parent f45b780900
commit eb95cacad4
10 changed files with 335 additions and 59 deletions
+7 -4
View File
@@ -4,6 +4,7 @@
#include <Logging.h>
#include <Serialization.h>
#include "../../../../src/CrossPointSettings.h"
#include "../converters/DirectPixelWriter.h"
#include "../converters/ImageDecoderFactory.h"
@@ -19,13 +20,13 @@ bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str());
namespace {
std::string getCachePath(const std::string& imagePath) {
std::string getCachePath(const std::string& imagePath, ImageDitherMode ditherMode) {
// Replace extension with .pxc (pixel cache)
size_t dotPos = imagePath.rfind('.');
if (dotPos != std::string::npos) {
return imagePath.substr(0, dotPos) + ".pxc";
return imagePath.substr(0, dotPos) + getImageDitherCacheSuffix(ditherMode) + ".pxc";
}
return imagePath + ".pxc";
return imagePath + getImageDitherCacheSuffix(ditherMode) + ".pxc";
}
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
@@ -110,7 +111,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
}
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
const ImageDitherMode ditherMode = imageDitherModeFromSetting(SETTINGS.imageDithering);
std::string cachePath = getCachePath(imagePath, ditherMode);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
return; // Successfully rendered from cache
}
@@ -139,6 +141,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
config.maxHeight = height;
config.useGrayscale = true;
config.useDithering = true;
config.ditherMode = ditherMode;
config.performanceMode = false;
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
config.cachePath = cachePath; // Enable caching during decode
+64 -4
View File
@@ -2,6 +2,8 @@
#include <stdint.h>
#include <cstring>
// 4x4 Bayer matrix for ordered dithering
inline const uint8_t bayer4x4[4][4] = {
{0, 8, 2, 10},
@@ -10,6 +12,13 @@ inline const uint8_t bayer4x4[4][4] = {
{15, 7, 13, 5},
};
inline uint8_t quantizeGray4Level(uint8_t gray) {
if (gray < 64) return 0;
if (gray < 128) return 1;
if (gray < 192) return 2;
return 3;
}
// Apply Bayer dithering and quantize to 4 levels (0-3)
// Stateless - works correctly with any pixel processing order
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
@@ -20,8 +29,59 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
if (adjusted < 64) return 0;
if (adjusted < 128) return 1;
if (adjusted < 192) return 2;
return 3;
return quantizeGray4Level((uint8_t)adjusted);
}
class DiffusedBayerDitherer {
public:
explicit DiffusedBayerDitherer(int width) : width(width) {
errorCurRow = new int16_t[width + 2]();
errorNextRow = new int16_t[width + 2]();
}
~DiffusedBayerDitherer() {
delete[] errorCurRow;
delete[] errorNextRow;
}
DiffusedBayerDitherer(const DiffusedBayerDitherer&) = delete;
DiffusedBayerDitherer& operator=(const DiffusedBayerDitherer&) = delete;
uint8_t processPixel(int gray, int x, int screenX, int screenY) {
int adjusted = gray + errorCurRow[x + 1];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
int thresholdAdjusted = adjusted + (bayer4x4[screenY & 3][screenX & 3] - 8) * 5;
if (thresholdAdjusted < 0) thresholdAdjusted = 0;
if (thresholdAdjusted > 255) thresholdAdjusted = 255;
uint8_t quantized = quantizeGray4Level((uint8_t)thresholdAdjusted);
int quantizedValue = quantized * 85;
int error = adjusted - quantizedValue;
errorCurRow[x + 2] += (error * 7) / 16;
errorNextRow[x] += (error * 3) / 16;
errorNextRow[x + 1] += (error * 5) / 16;
errorNextRow[x + 2] += error / 16;
return quantized;
}
void nextRow() {
int16_t* tmp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = tmp;
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
}
void reset() {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
}
private:
int width;
int16_t* errorCurRow;
int16_t* errorNextRow;
};
@@ -11,11 +11,44 @@ struct ImageDimensions {
int16_t height;
};
enum class ImageDitherMode : uint8_t {
Bayer = 0,
Atkinson = 1,
DiffusedBayer = 2,
COUNT,
};
inline ImageDitherMode imageDitherModeFromSetting(uint8_t value) {
switch (static_cast<ImageDitherMode>(value)) {
case ImageDitherMode::Bayer:
case ImageDitherMode::Atkinson:
case ImageDitherMode::DiffusedBayer:
return static_cast<ImageDitherMode>(value);
case ImageDitherMode::COUNT:
default:
return ImageDitherMode::Bayer;
}
}
inline const char* getImageDitherCacheSuffix(ImageDitherMode mode) {
switch (mode) {
case ImageDitherMode::Atkinson:
return ".atkinson";
case ImageDitherMode::DiffusedBayer:
return ".diffused-bayer";
case ImageDitherMode::Bayer:
case ImageDitherMode::COUNT:
default:
return ".bayer";
}
}
struct RenderConfig {
int x, y;
int maxWidth, maxHeight;
bool useGrayscale = true;
bool useDithering = true;
ImageDitherMode ditherMode = ImageDitherMode::Bayer;
bool performanceMode = false;
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
@@ -1,5 +1,6 @@
#include "JpegToFramebufferConverter.h"
#include <BitmapHelpers.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
@@ -39,6 +40,10 @@ struct JpegContext {
PixelCache cache;
bool caching;
int currentDitherRow;
AtkinsonDitherer* atkinsonDitherer;
DiffusedBayerDitherer* diffusedBayerDitherer;
JpegContext()
: renderer(nullptr),
config(nullptr),
@@ -50,9 +55,59 @@ struct JpegContext {
dstHeight(0),
fineScaleFP(1 << 16),
invScaleFP(1 << 16),
caching(false) {}
caching(false),
currentDitherRow(-1),
atkinsonDitherer(nullptr),
diffusedBayerDitherer(nullptr) {}
~JpegContext() {
delete atkinsonDitherer;
delete diffusedBayerDitherer;
}
};
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.config || !ctx.config->useDithering) {
return quantizeGray4Level(gray);
}
switch (ctx.config->ditherMode) {
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);
}
// 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) {
@@ -136,7 +191,6 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
const bool useDithering = ctx->config->useDithering;
const bool caching = ctx->caching;
const int32_t fineScaleFP = ctx->fineScaleFP;
const int32_t invScaleFP = ctx->invScaleFP;
@@ -181,19 +235,14 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
if (fineScaleFP == FP_ONE) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
prepareDitherRow(*ctx, dstY);
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;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
pw.writePixel(outX, dithered);
if (caching) cw.writePixel(outX, dithered);
}
@@ -215,6 +264,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
prepareDitherRow(*ctx, dstY);
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
const int32_t srcFyFP = dstY * invScaleFP;
@@ -246,13 +296,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
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;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
pw.writePixel(outX, dithered);
if (caching) cw.writePixel(outX, dithered);
}
@@ -269,13 +313,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
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;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
pw.writePixel(outX, dithered);
if (caching) cw.writePixel(outX, dithered);
}
@@ -295,13 +333,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
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;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
pw.writePixel(outX, dithered);
if (caching) cw.writePixel(outX, dithered);
}
@@ -312,6 +344,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
prepareDitherRow(*ctx, dstY);
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
const int32_t srcFyFP = dstY * invScaleFP;
@@ -328,13 +361,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
if (lx >= validW) lx = validW - 1;
uint8_t gray = row[lx];
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
pw.writePixel(outX, dithered);
if (caching) cw.writePixel(outX, dithered);
}
@@ -479,6 +506,27 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
}
}
if (config.useDithering) {
switch (config.ditherMode) {
case ImageDitherMode::Atkinson:
ctx.atkinsonDitherer = 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 = 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;
}
}
unsigned long decodeStart = millis();
rc = jpeg->decode(0, 0, jpegScaleOption);
unsigned long decodeTime = millis() - decodeStart;
@@ -1,5 +1,6 @@
#include "PngToFramebufferConverter.h"
#include <BitmapHelpers.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
@@ -36,6 +37,9 @@ struct PngContext {
bool caching;
uint8_t* grayLineBuffer;
int currentDitherRow;
AtkinsonDitherer* atkinsonDitherer;
DiffusedBayerDitherer* diffusedBayerDitherer;
PngContext()
: renderer(nullptr),
@@ -49,9 +53,59 @@ struct PngContext {
dstHeight(0),
lastDstY(-1),
caching(false),
grayLineBuffer(nullptr) {}
grayLineBuffer(nullptr),
currentDitherRow(-1),
atkinsonDitherer(nullptr),
diffusedBayerDitherer(nullptr) {}
~PngContext() {
delete atkinsonDitherer;
delete diffusedBayerDitherer;
}
};
void prepareDitherRow(PngContext& 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(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) {
if (!ctx.config || !ctx.config->useDithering) {
return quantizeGray4Level(gray);
}
switch (ctx.config->ditherMode) {
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);
}
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// avoiding the need for global file state.
void* pngOpenWithHandle(const char* filename, int32_t* size) {
@@ -205,7 +259,6 @@ int pngDrawCallback(PNGDRAW* pDraw) {
int dstWidth = ctx->dstWidth;
int outXBase = ctx->config->x;
int screenWidth = ctx->screenWidth;
bool useDithering = ctx->config->useDithering;
bool caching = ctx->caching;
// Pre-compute orientation and render-mode state once per row
@@ -219,6 +272,8 @@ int pngDrawCallback(PNGDRAW* pDraw) {
cw.beginRow(outY, ctx->config->y);
}
prepareDitherRow(*ctx, dstY);
int srcX = 0;
int error = 0;
@@ -227,13 +282,7 @@ int pngDrawCallback(PNGDRAW* pDraw) {
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
uint8_t ditheredGray = ditherGray(*ctx, gray, dstX, outX, outY);
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
@@ -385,6 +434,27 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
}
if (config.useDithering) {
switch (config.ditherMode) {
case ImageDitherMode::Atkinson:
ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(ctx.dstWidth);
if (!ctx.atkinsonDitherer) {
LOG_ERR("PNG", "Failed to allocate Atkinson ditherer, falling back to Bayer");
}
break;
case ImageDitherMode::DiffusedBayer:
ctx.diffusedBayerDitherer = new (std::nothrow) DiffusedBayerDitherer(ctx.dstWidth);
if (!ctx.diffusedBayerDitherer) {
LOG_ERR("PNG", "Failed to allocate diffused Bayer ditherer, falling back to Bayer");
}
break;
case ImageDitherMode::Bayer:
case ImageDitherMode::COUNT:
default:
break;
}
}
unsigned long decodeStart = millis();
rc = png->decode(&ctx, 0);
unsigned long decodeTime = millis() - decodeStart;
+4
View File
@@ -70,6 +70,10 @@ STR_IMAGES: "Images"
STR_IMAGES_DISPLAY: "Display"
STR_IMAGES_PLACEHOLDER: "Placeholder"
STR_IMAGES_SUPPRESS: "Suppress"
STR_IMAGE_DITHERING: "Image Dithering"
STR_IMAGE_DITHER_BAYER: "Bayer"
STR_IMAGE_DITHER_ATKINSON: "Atkinson"
STR_IMAGE_DITHER_DIFFUSED_BAYER: "Diffused Bayer"
STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Create fallback for invalid TOC"
STR_SHORT_PWR_BTN: "Short Power Button Click"
STR_ORIENTATION: "Reading Orientation"