From 0c71e0b13ff71796057cbd09e787b68b852b4f75 Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 13 Feb 2026 14:49:42 -0500 Subject: [PATCH] fix: Use hash-based block dithering for BW-boundary letterbox fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/activities/boot_sleep/SleepActivity.cpp | 97 ++++++++++++++++----- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 6f41338c..ff91a209 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -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 --- // Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep. 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(sumTop / countTop) : 128; data.avgB = countBot > 0 ? static_cast(sumBot / countBot) : 128; data.valid = true; - } else if (imgX > 0) { // Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns data.horizontal = false; @@ -230,9 +262,9 @@ LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, data.valid = true; } + bitmap.rewindToData(); free(outputRow); free(rowBytes); - bitmap.rewindToData(); 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); - // For SOLID: snap to nearest e-ink level then convert to 2-bit - // For DITHERED: use the raw average, Bayer dithering produces the 2-bit level per pixel - const uint8_t colorA = isSolid ? snapToEinkLevel(data.avgA) : data.avgA; - const uint8_t colorB = isSolid ? snapToEinkLevel(data.avgB) : data.avgB; - const uint8_t solidA = colorA / 85; // only used for SOLID - const uint8_t solidB = colorB / 85; + // 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 levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0; if (data.horizontal) { - // Top letterbox if (data.letterboxA > 0) { for (int y = 0; y < data.letterboxA; y++) - for (int x = 0; x < renderer.getScreenWidth(); x++) - renderer.drawPixelGray(x, y, isSolid ? solidA : quantizeBayerDither(colorA, x, y)); + for (int x = 0; x < renderer.getScreenWidth(); x++) { + 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) { const int start = renderer.getScreenHeight() - data.letterboxB; for (int y = start; y < renderer.getScreenHeight(); y++) - for (int x = 0; x < renderer.getScreenWidth(); x++) - renderer.drawPixelGray(x, y, isSolid ? solidB : quantizeBayerDither(colorB, x, y)); + for (int x = 0; x < renderer.getScreenWidth(); x++) { + 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 { - // Left letterbox if (data.letterboxA > 0) { for (int x = 0; x < data.letterboxA; x++) - for (int y = 0; y < renderer.getScreenHeight(); y++) - renderer.drawPixelGray(x, y, isSolid ? solidA : quantizeBayerDither(colorA, x, y)); + for (int y = 0; y < renderer.getScreenHeight(); 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) { const int start = renderer.getScreenWidth() - data.letterboxB; for (int x = start; x < renderer.getScreenWidth(); x++) - for (int y = 0; y < renderer.getScreenHeight(); y++) - renderer.drawPixelGray(x, y, isSolid ? solidB : quantizeBayerDither(colorB, x, y)); + for (int y = 0; y < renderer.getScreenHeight(); 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); + } } } }