fix: Use hash-based block dithering for BW-boundary letterbox fills
Pixel-level Bayer dithering in the 171-254 gray range creates a high-frequency checkerboard in the BW pass that causes e-ink display crosstalk during HALF_REFRESH, washing out cover images. Replace with 2x2 hash-based block dithering for this specific gray range — each block gets a uniform level (2 or 3) via a spatial hash, avoiding single-pixel alternation while approximating the target gray. Standard Bayer dithering remains for all other gray ranges. Also removes all debug instrumentation from the investigation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -70,6 +70,39 @@ 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;
|
||||||
@@ -195,7 +228,6 @@ LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY,
|
|||||||
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
|
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
|
||||||
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
|
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
|
||||||
data.valid = true;
|
data.valid = true;
|
||||||
|
|
||||||
} else if (imgX > 0) {
|
} else if (imgX > 0) {
|
||||||
// Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
|
// Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
|
||||||
data.horizontal = false;
|
data.horizontal = false;
|
||||||
@@ -230,9 +262,9 @@ LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY,
|
|||||||
data.valid = true;
|
data.valid = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bitmap.rewindToData();
|
||||||
free(outputRow);
|
free(outputRow);
|
||||||
free(rowBytes);
|
free(rowBytes);
|
||||||
bitmap.rewindToData();
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,40 +277,65 @@ 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 SOLID: snap to nearest e-ink level then convert to 2-bit
|
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
|
||||||
// For DITHERED: use the raw average, Bayer dithering produces the 2-bit level per pixel
|
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
|
||||||
const uint8_t colorA = isSolid ? snapToEinkLevel(data.avgA) : data.avgA;
|
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
|
||||||
const uint8_t colorB = isSolid ? snapToEinkLevel(data.avgB) : data.avgB;
|
//
|
||||||
const uint8_t solidA = colorA / 85; // only used for SOLID
|
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
|
||||||
const uint8_t solidB = colorB / 85;
|
// 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 levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
|
||||||
|
|
||||||
if (data.horizontal) {
|
if (data.horizontal) {
|
||||||
// Top letterbox
|
|
||||||
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++) {
|
||||||
renderer.drawPixelGray(x, y, isSolid ? solidA : quantizeBayerDither(colorA, x, y));
|
uint8_t lv;
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Bottom letterbox
|
|
||||||
if (data.letterboxB > 0) {
|
if (data.letterboxB > 0) {
|
||||||
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++) {
|
||||||
renderer.drawPixelGray(x, y, isSolid ? solidB : quantizeBayerDither(colorB, x, y));
|
uint8_t lv;
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Left letterbox
|
|
||||||
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++) {
|
||||||
renderer.drawPixelGray(x, y, isSolid ? solidA : quantizeBayerDither(colorA, x, y));
|
uint8_t lv;
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Right letterbox
|
|
||||||
if (data.letterboxB > 0) {
|
if (data.letterboxB > 0) {
|
||||||
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++) {
|
||||||
renderer.drawPixelGray(x, y, isSolid ? solidB : quantizeBayerDither(colorB, x, y));
|
uint8_t lv;
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user