fix: Use double FAST_REFRESH for dithered letterbox sleep covers

Replace HALF_REFRESH with double FAST_REFRESH technique for the BW
pass when dithered letterbox fill is active. This avoids the e-ink
crosstalk and image corruption that occurred when HALF_REFRESH drove
large areas of dithered gray pixels simultaneously.

Revert the hash-based block dithering workaround (bayerCrossesBwBoundary,
hashBlockDither) back to standard Bayer dithering for all gray ranges,
since the root cause was HALF_REFRESH rather than the dithering pattern
itself.

Letterbox fill is now included in all three render passes (BW, LSB, MSB)
so the greyscale LUT treats letterbox pixels identically to cover pixels,
maintaining color-matched edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-19 11:33:45 -05:00
parent 013a738144
commit 0fda9031fd

View File

@@ -21,6 +21,11 @@
#include "util/BookSettings.h" #include "util/BookSettings.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
// Sleep cover refresh strategy when dithered letterbox fill is active:
// 1 = Double FAST_REFRESH (clear to white, then render content -- avoids HALF_REFRESH crosstalk)
// 0 = Standard HALF_REFRESH (original behavior)
#define USE_SLEEP_DOUBLE_FAST_REFRESH 1
namespace { namespace {
// Number of source pixels along the image edge to average for the dominant color // Number of source pixels along the image edge to average for the dominant color
@@ -74,37 +79,6 @@ uint8_t quantizeBayerDither(int gray, int x, int y) {
} }
} }
// Check whether a gray value would produce a dithered mix that crosses the
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
// creating a high-frequency checkerboard that causes e-ink display crosstalk
// and washes out adjacent content during HALF_REFRESH.
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
bool bayerCrossesBwBoundary(uint8_t gray) { return gray > 170 && gray < 255; }
// Hash-based block dithering for BW-boundary gray values (171-254).
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
// determined by a deterministic spatial hash. The proportion of level-3 blocks
// approximates the target gray. Unlike Bayer, the pattern is irregular
// (noise-like), making it much less visually obvious at the same block size.
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
// identical levels across BW, LSB, and MSB render passes.
static constexpr int BW_DITHER_BLOCK = 2;
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
const int bx = x / BW_DITHER_BLOCK;
const int by = y / BW_DITHER_BLOCK;
// Fast mixing hash (splitmix32-inspired)
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
h ^= h >> 16;
h *= 0x45d9f3bu;
h ^= h >> 16;
// Proportion of level-3 blocks needed to approximate the target gray
const float ratio = (avg - 170.0f) / 85.0f;
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
return (h < threshold) ? 3 : 2;
}
// --- Edge average cache --- // --- Edge average cache ---
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep. // Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
constexpr uint8_t EDGE_CACHE_VERSION = 2; constexpr uint8_t EDGE_CACHE_VERSION = 2;
@@ -278,19 +252,6 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID); const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
//
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
// crosstalk, and the irregular hash pattern is much less visible than a regular
// Bayer grid at the same block size.
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
// For solid mode: snap to nearest e-ink level
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0; const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0; const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
@@ -298,13 +259,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
if (data.letterboxA > 0) { if (data.letterboxA > 0) {
for (int y = 0; y < data.letterboxA; y++) for (int y = 0; y < data.letterboxA; y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) { for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv; const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
if (isSolid)
lv = levelA;
else if (hashA)
lv = hashBlockDither(data.avgA, x, y);
else
lv = quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -312,13 +267,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const int start = renderer.getScreenHeight() - data.letterboxB; const int start = renderer.getScreenHeight() - data.letterboxB;
for (int y = start; y < renderer.getScreenHeight(); y++) for (int y = start; y < renderer.getScreenHeight(); y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) { for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv; const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
if (isSolid)
lv = levelB;
else if (hashB)
lv = hashBlockDither(data.avgB, x, y);
else
lv = quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -326,13 +275,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
if (data.letterboxA > 0) { if (data.letterboxA > 0) {
for (int x = 0; x < data.letterboxA; x++) for (int x = 0; x < data.letterboxA; x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) { for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv; const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
if (isSolid)
lv = levelA;
else if (hashA)
lv = hashBlockDither(data.avgA, x, y);
else
lv = quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -340,13 +283,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const int start = renderer.getScreenWidth() - data.letterboxB; const int start = renderer.getScreenWidth() - data.letterboxB;
for (int x = start; x < renderer.getScreenWidth(); x++) for (int x = start; x < renderer.getScreenWidth(); x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) { for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv; const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
if (isSolid)
lv = levelB;
else if (hashB)
lv = hashBlockDither(data.avgB, x, y);
else
lv = quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -543,35 +480,47 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
const bool hasGreyscale = bitmap.hasGreyscale() && const bool hasGreyscale = bitmap.hasGreyscale() &&
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Draw letterbox fill (BW pass) const bool isInverted =
if (fillData.valid) { SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE;
drawLetterboxFill(renderer, fillData, fillMode);
#if USE_SLEEP_DOUBLE_FAST_REFRESH
const bool useDoubleFast =
fillData.valid && fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_DITHERED;
#else
const bool useDoubleFast = false;
#endif
if (useDoubleFast) {
// Double FAST_REFRESH technique: avoids HALF_REFRESH crosstalk with dithered letterbox.
// Pass 1: clear to white baseline
renderer.clearScreen();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
// Pass 2: render actual content and display
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (isInverted) renderer.invertScreen();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
// Standard path: single HALF_REFRESH
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (isInverted) renderer.invertScreen();
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
renderer.invertScreen();
}
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
if (hasGreyscale) { if (hasGreyscale) {
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (fillData.valid) { if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
drawLetterboxFill(renderer, fillData, fillMode);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers(); renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (fillData.valid) { if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
drawLetterboxFill(renderer, fillData, fillMode);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers(); renderer.copyGrayscaleMsbBuffers();