Extended bitmap viewer by option to togglie between grayscale and bw mode

This commit is contained in:
jpirnay
2026-04-08 09:37:08 +02:00
parent e828245c6b
commit 66508258d3
8 changed files with 198 additions and 73 deletions
@@ -60,7 +60,11 @@ struct RenderConfig {
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
// If true, the decoder uses a 1-bit Atkinson dither and emits only the values 0/3 — suitable for
// pure black-and-white display (no grayscale planes). The 4-level dither path produces values 1/2
// which the BW DirectPixelWriter collapses to black, making mid-grays render very dark.
bool monochromeOutput = false;
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
class ImageToFramebufferDecoder {
@@ -1,8 +1,6 @@
#include "JpegToFramebufferConverter.h"
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
#include <BitmapHelpers.h>
#endif
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
@@ -42,6 +40,11 @@ struct JpegContext {
PixelCache cache;
bool caching;
// 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;
Atkinson1BitDitherer* atkinson1BitDitherer;
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
int currentDitherRow;
AtkinsonDitherer* atkinsonDitherer;
@@ -59,7 +62,9 @@ struct JpegContext {
dstHeight(0),
fineScaleFP(1 << 16),
invScaleFP(1 << 16),
caching(false)
caching(false),
oneBitDitherRow(-1),
atkinson1BitDitherer(nullptr)
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
,
currentDitherRow(-1),
@@ -69,14 +74,32 @@ struct JpegContext {
{
}
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
~JpegContext() {
delete atkinson1BitDitherer;
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
delete atkinsonDitherer;
delete 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;
@@ -96,6 +119,10 @@ void prepareDitherRow(JpegContext& ctx, int dstY) {
}
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);
}
@@ -121,7 +148,9 @@ uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int out
}
#else
uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) {
(void)ctx;
if (ctx.atkinson1BitDitherer) {
return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0;
}
(void)localX;
return applyBayerDither4Level(gray, outX, outY);
}
@@ -254,6 +283,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
if (fineScaleFP == 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
@@ -285,6 +315,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
prepareOneBitDitherRow(*ctx, dstY);
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
prepareDitherRow(*ctx, dstY);
#endif
@@ -367,6 +398,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
// === 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
@@ -531,7 +563,16 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
}
}
if (config.useDithering) {
// 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 = 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 (config.ditherMode) {
case ImageDitherMode::Atkinson:
@@ -38,12 +38,11 @@ struct PngContext {
uint8_t* grayLineBuffer;
// When the renderer is in BW mode the framebuffer is 1 bpp and the
// DirectPixelWriter collapses any value < 3 to black. The 4-level dither
// path therefore turns mid-grays into solid black. In that case we run a
// proper 1-bit Atkinson dither (matching PngToBmpConverter's BW path) and
// emit only values 0 or 3, which round-trip cleanly through the BW writer.
bool renderModeIsBW;
// When the caller requests monochrome output (RenderConfig::monochromeOutput),
// we run a proper 1-bit Atkinson dither (matching PngToBmpConverter's BW path)
// and emit only values 0 or 3, which round-trip cleanly through the BW writer's
// `pixelValue < 3` rule. The 4-level dither path collapses mid-grays to solid
// black under that rule.
int oneBitDitherRow;
Atkinson1BitDitherer* atkinson1BitDitherer;
@@ -66,7 +65,6 @@ struct PngContext {
lastDstY(-1),
caching(false),
grayLineBuffer(nullptr),
renderModeIsBW(false),
oneBitDitherRow(-1),
atkinson1BitDitherer(nullptr)
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
@@ -494,17 +492,13 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
}
// When the renderer is in BW mode, use a 1-bit Atkinson ditherer instead of
// the 4-level paths below. The 4-level dither produces values 1-2 for mid
// grays, which DirectPixelWriter then collapses to black under its `< 3`
// BW rule, making images render very dark. The 1-bit ditherer emits only
// 0 or 3 so the BW writer maps cleanly to black/white. Caching still uses
// the 2-bit cache file format, but caching is disabled in this path
// (BmpViewerActivity passes an empty cachePath).
ctx.renderModeIsBW = (renderer.getRenderMode() == GfxRenderer::BW);
LOG_DBG("PNG", "Render mode at decode: %d (BW=%d) -> 1bit dither=%d", (int)renderer.getRenderMode(),
(int)GfxRenderer::BW, ctx.renderModeIsBW ? 1 : 0);
if (ctx.renderModeIsBW) {
// When the caller explicitly requests monochrome output, use a 1-bit Atkinson
// ditherer instead of the 4-level paths below. The 4-level dither produces
// values 1-2 for mid grays, which DirectPixelWriter then collapses to black
// under its `< 3` BW rule, making images render very dark in BW-only mode.
// The 1-bit ditherer emits only 0 or 3 so the BW writer maps cleanly to
// black/white.
if (config.monochromeOutput) {
ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(ctx.dstWidth);
if (!ctx.atkinson1BitDitherer) {
LOG_ERR("PNG", "Failed to allocate 1-bit Atkinson ditherer, falling back to 4-level dither");
+2
View File
@@ -458,5 +458,7 @@ STR_COULD_NOT_RENDER_IMAGE: "Could not render image"
STR_FAILED_TO_SET_SLEEP_SCREEN: "Failed to set sleep screen"
STR_SET_SLEEP_SCREEN: "Set Sleep"
STR_SLEEP_SCREEN_SET: "Sleep screen updated!"
STR_IMAGE_DISPLAY_BW: ">> B&W"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Gray"
STR_WEATHER_MOON_INFO: "Moon"
STR_WEATHER_SUN_INFO: "Sun"
+2
View File
@@ -336,6 +336,8 @@ STR_COULD_NOT_RENDER_IMAGE: "Bild konnte nicht dargestellt werden"
STR_FAILED_TO_SET_SLEEP_SCREEN: "Standby-Bild konnte nicht gesetzt werden"
STR_SET_SLEEP_SCREEN: "Standby-Bild setzen"
STR_SLEEP_SCREEN_SET: "Standby-Bild aktualisiert!"
STR_IMAGE_DISPLAY_BW: ">> S/W"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Grau"
STR_WEATHER: "Wetter"
STR_WEATHER_LOCATION: "Ort"
+17 -31
View File
@@ -601,23 +601,13 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver
constexpr int sectionSpacing = 10;
const int availableWidth = pageWidth - 20;
int textBlockHeight = 0;
int textBlockHeight = lineHeight10; // progress line (always present here)
if (hasTitle) {
textBlockHeight += lineHeight12;
if (hasAuthor) {
textBlockHeight += lineSpacing;
} else if (hasProgress) {
textBlockHeight += sectionSpacing;
}
textBlockHeight += hasAuthor ? lineSpacing : sectionSpacing;
}
if (hasAuthor) {
textBlockHeight += lineHeight10;
if (hasProgress) {
textBlockHeight += sectionSpacing;
}
}
if (hasProgress) {
textBlockHeight += lineHeight10;
textBlockHeight += lineHeight10 + sectionSpacing;
}
const bool textBlack = (overlayMode != 3);
@@ -638,8 +628,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver
const std::string titleStr =
renderer.truncatedText(BOOKERLY_12_FONT_ID, overlayInfo.title.c_str(), availableWidth, EpdFontFamily::BOLD);
renderer.drawCenteredText(BOOKERLY_12_FONT_ID, currentY, titleStr.c_str(), textBlack, EpdFontFamily::BOLD);
const int spacingAfterTitle = hasAuthor ? lineSpacing : (hasProgress ? sectionSpacing : lineSpacing);
currentY += lineHeight12 + spacingAfterTitle;
currentY += lineHeight12 + (hasAuthor ? lineSpacing : sectionSpacing);
}
if (hasAuthor) {
@@ -648,23 +637,20 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver
currentY += lineHeight10 + sectionSpacing;
}
if (hasProgress) {
std::string progressStr;
if (!overlayInfo.chapterName.empty()) {
const std::string prefix = "";
const int prefixWidth = renderer.getTextWidth(UI_10_FONT_ID, prefix.c_str());
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, overlayInfo.progressSuffix.c_str());
const int maxChapterWidth = availableWidth - prefixWidth - suffixWidth;
const std::string truncatedChapter =
maxChapterWidth > 0
? renderer.truncatedText(UI_10_FONT_ID, overlayInfo.chapterName.c_str(), maxChapterWidth)
: "";
progressStr = prefix + truncatedChapter + overlayInfo.progressSuffix;
} else {
progressStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), availableWidth);
}
renderer.drawCenteredText(UI_10_FONT_ID, currentY, progressStr.c_str(), textBlack);
std::string progressStr;
if (!overlayInfo.chapterName.empty()) {
const std::string prefix = "";
const int prefixWidth = renderer.getTextWidth(UI_10_FONT_ID, prefix.c_str());
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, overlayInfo.progressSuffix.c_str());
const int maxChapterWidth = availableWidth - prefixWidth - suffixWidth;
const std::string truncatedChapter =
maxChapterWidth > 0 ? renderer.truncatedText(UI_10_FONT_ID, overlayInfo.chapterName.c_str(), maxChapterWidth)
: "";
progressStr = prefix + truncatedChapter + overlayInfo.progressSuffix;
} else {
progressStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), availableWidth);
}
renderer.drawCenteredText(UI_10_FONT_ID, currentY, progressStr.c_str(), textBlack);
};
drawOverlay();
+105 -16
View File
@@ -150,14 +150,44 @@ bool BmpViewerActivity::renderBmpImage(const bool showControls) {
GUI.fillPopupProgress(renderer, popupRect, 50);
bmpHasGreyscale = bitmap.hasGreyscale();
// Only render in grayscale when the bitmap actually carries greyscale data AND the user has it enabled.
const bool renderGrayscale = bmpHasGreyscale && grayscaleDisplay;
// Draw control hints. btn2 only shows the BW/Gray toggle when the bitmap supports greyscale —
// pure 1-bit BMPs have nothing to toggle. The label shows the *target* mode (what pressing it switches to).
const auto drawHints = [&]() {
if (!showControls) return;
const char* modeLabel =
bmpHasGreyscale ? (grayscaleDisplay ? tr(STR_IMAGE_DISPLAY_BW) : tr(STR_IMAGE_DISPLAY_GRAYSCALE)) : "";
const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, "", tr(STR_SET_SLEEP_SCREEN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
};
renderer.setRenderMode(GfxRenderer::BW);
renderer.clearScreen();
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0);
if (showControls) {
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
drawHints();
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
if (renderGrayscale) {
// Multi-pass 4-level grayscale render — mirrors SleepActivity::renderBitmapSleepScreen.
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0);
renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0);
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
}
file.close();
return true;
}
@@ -182,7 +212,6 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) {
computeCenteredImagePlacement(dims.width, dims.height, pageWidth, pageHeight, x, y, renderWidth, renderHeight);
GUI.fillPopupProgress(renderer, popupRect, 50);
renderer.clearScreen();
RenderConfig config{};
config.x = x;
@@ -198,23 +227,74 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) {
config.ditherMode = ImageDitherMode::Bayer;
#endif
// Helper to draw the on-screen control hints. The btn3 label shows the *other* mode
// (i.e. what pressing it would switch to).
const auto drawHints = [&]() {
if (!showControls) return;
const char* modeLabel = grayscaleDisplay ? tr(STR_IMAGE_DISPLAY_BW) : tr(STR_IMAGE_DISPLAY_GRAYSCALE);
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
const char* btn3Label = grayscaleDisplay ? I18N.get(getCurrentDitherModeLabel()) : modeLabel;
const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, btn3Label, tr(STR_SET_SLEEP_SCREEN));
#else
const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, "", tr(STR_SET_SLEEP_SCREEN));
#endif
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
};
if (!grayscaleDisplay) {
// Pure black-and-white path: single decode with 1-bit Atkinson dither.
config.monochromeOutput = true;
renderer.setRenderMode(GfxRenderer::BW);
renderer.clearScreen();
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
return false;
}
drawHints();
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
return true;
}
// Grayscale path: three decode passes (BW + LSB + MSB), 4-level dither in all of them.
// Mirrors SleepActivity::renderCustomSleepScreen so the BW plane carries the same
// 4-level quantization as the gray planes layered on top.
config.monochromeOutput = false;
renderer.setRenderMode(GfxRenderer::BW);
renderer.clearScreen();
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
return false;
}
if (showControls) {
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
const auto labels =
mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN));
#else
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN));
#endif
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
drawHints();
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return false;
}
renderer.copyGrayscaleLsbBuffers();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return false;
}
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
void BmpViewerActivity::toggleDisplayMode() {
grayscaleDisplay = !grayscaleDisplay;
if (!renderCurrentImage()) {
renderError(tr(STR_COULD_NOT_RENDER_IMAGE));
}
}
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
StrId BmpViewerActivity::getCurrentDitherModeLabel() const {
switch (imageDitherModeFromSetting(imageDitherMode)) {
@@ -312,8 +392,17 @@ void BmpViewerActivity::loop() {
return;
}
// Confirm: toggle between 1-bit B&W and 4-level grayscale display.
// For decoded images this always applies; for BMPs it only makes sense when the bitmap
// actually carries greyscale data (1-bit BMPs have nothing to toggle).
const bool toggleSupported = isBmpFile(filePath) ? bmpHasGreyscale : true;
if (toggleSupported && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
toggleDisplayMode();
return;
}
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
if (!isBmpFile(filePath) && mappedInput.wasReleased(MappedInputManager::Button::Left)) {
if (!isBmpFile(filePath) && grayscaleDisplay && mappedInput.wasReleased(MappedInputManager::Button::Left)) {
cycleDitherMode();
return;
}
+7
View File
@@ -24,9 +24,16 @@ class BmpViewerActivity final : public Activity {
uint8_t initialImageDitherMode;
bool imageDitherSettingsDirty;
#endif
// Per-session toggle: monochrome (1-bit Atkinson, single decode) vs grayscale (4-level dither, multipass).
// Not persisted — defaults to grayscale every time the viewer opens.
bool grayscaleDisplay = true;
// True after a successful BMP render iff the bitmap actually carries greyscale data.
// Used to gate the BW/Gray toggle in loop() — pure 1-bit BMPs cannot be toggled.
bool bmpHasGreyscale = false;
bool renderCurrentImage(bool showControls = true);
bool renderBmpImage(bool showControls = true);
bool renderDecodedImage(bool showControls = true);
void toggleDisplayMode();
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
void cycleDitherMode();
StrId getCurrentDitherModeLabel() const;