refactor: Revert letterbox fill to Dithered/Solid/None with edge caching

Simplify letterbox fill modes back to Dithered (default), Solid, and
None. Remove the Extend Edges mode and all per-pixel edge replication
code. Restore Bayer ordered dithering for the Dithered fill mode.

Re-introduce edge average caching so cover edge computations persist
across sleep cycles, stored as a small binary file alongside the cover
BMP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-13 11:12:27 -05:00
parent 31878a77bc
commit ea11d2f7d3
5 changed files with 149 additions and 244 deletions

View File

@@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 32; constexpr uint8_t SETTINGS_COUNT = 31;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
// Validate front button mapping to ensure each hardware button is unique. // Validate front button mapping to ensure each hardware button is unique.
@@ -119,7 +119,6 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, fadingFix); serialization::writePod(outputFile, fadingFix);
serialization::writePod(outputFile, embeddedStyle); serialization::writePod(outputFile, embeddedStyle);
serialization::writePod(outputFile, sleepScreenLetterboxFill); serialization::writePod(outputFile, sleepScreenLetterboxFill);
serialization::writePod(outputFile, sleepScreenGradientDir);
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
outputFile.close(); outputFile.close();
@@ -227,7 +226,7 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT); readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenGradientDir, SLEEP_SCREEN_GRADIENT_DIR_COUNT); { uint8_t _ignore; serialization::readPod(inputFile, _ignore); } // legacy: sleepScreenGradientDir
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
} while (false); } while (false);

View File

@@ -32,13 +32,11 @@ class CrossPointSettings {
SLEEP_SCREEN_COVER_FILTER_COUNT SLEEP_SCREEN_COVER_FILTER_COUNT
}; };
enum SLEEP_SCREEN_LETTERBOX_FILL { enum SLEEP_SCREEN_LETTERBOX_FILL {
LETTERBOX_NONE = 0, LETTERBOX_DITHERED = 0,
LETTERBOX_SOLID = 1, LETTERBOX_SOLID = 1,
LETTERBOX_BLENDED = 2, LETTERBOX_NONE = 2,
LETTERBOX_GRADIENT = 3,
SLEEP_SCREEN_LETTERBOX_FILL_COUNT SLEEP_SCREEN_LETTERBOX_FILL_COUNT
}; };
enum SLEEP_SCREEN_GRADIENT_DIR { GRADIENT_TO_WHITE = 0, GRADIENT_TO_BLACK = 1, SLEEP_SCREEN_GRADIENT_DIR_COUNT };
// Status bar display type enum // Status bar display type enum
enum STATUS_BAR_MODE { enum STATUS_BAR_MODE {
@@ -133,10 +131,8 @@ class CrossPointSettings {
uint8_t sleepScreenCoverMode = FIT; uint8_t sleepScreenCoverMode = FIT;
// Sleep screen cover filter // Sleep screen cover filter
uint8_t sleepScreenCoverFilter = NO_FILTER; uint8_t sleepScreenCoverFilter = NO_FILTER;
// Sleep screen letterbox fill mode (None / Solid / Blended / Gradient) // Sleep screen letterbox fill mode (Dithered / Solid / None)
uint8_t sleepScreenLetterboxFill = LETTERBOX_GRADIENT; uint8_t sleepScreenLetterboxFill = LETTERBOX_DITHERED;
// Sleep screen gradient direction (towards white or black)
uint8_t sleepScreenGradientDir = GRADIENT_TO_WHITE;
// Status bar settings // Status bar settings
uint8_t statusBar = FULL; uint8_t statusBar = FULL;
// Text rendering settings // Text rendering settings

View File

@@ -19,9 +19,7 @@ inline std::vector<SettingInfo> getSettingsList() {
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"), {"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill, SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill,
{"None", "Solid", "Blended", "Gradient"}, "sleepScreenLetterboxFill", "Display"), {"Dithered", "Solid", "None"}, "sleepScreenLetterboxFill", "Display"),
SettingInfo::Enum("Gradient Direction", &CrossPointSettings::sleepScreenGradientDir, {"To White", "To Black"},
"sleepScreenGradientDir", "Display"),
SettingInfo::Enum( SettingInfo::Enum(
"Status Bar", &CrossPointSettings::statusBar, "Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}, {"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},

View File

@@ -1,6 +1,5 @@
#include "SleepActivity.h" #include "SleepActivity.h"
#include <BitmapHelpers.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
@@ -9,6 +8,7 @@
#include <Xtc.h> #include <Xtc.h>
#include <algorithm> #include <algorithm>
#include <cmath>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
@@ -19,36 +19,62 @@
namespace { namespace {
// Number of source pixels along the image edge to average for the gradient color // Number of source pixels along the image edge to average for the dominant color
constexpr int EDGE_SAMPLE_DEPTH = 20; constexpr int EDGE_SAMPLE_DEPTH = 20;
// Map a 2-bit quantized pixel value to an 8-bit grayscale value // Map a 2-bit quantized pixel value to an 8-bit grayscale value
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; } constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
// Edge gradient data produced by sampleBitmapEdges and consumed by drawLetterboxGradients. // Letterbox fill data: one average gray value per edge (top/bottom or left/right).
// edgeA is the "first" edge (top or left), edgeB is the "second" edge (bottom or right). struct LetterboxFillData {
struct LetterboxGradientData { uint8_t avgA = 128; // average gray of edge A (top or left)
uint8_t* edgeA = nullptr; uint8_t avgB = 128; // average gray of edge B (bottom or right)
uint8_t* edgeB = nullptr;
int edgeCount = 0;
int letterboxA = 0; // pixel size of the first letterbox area (top or left) int letterboxA = 0; // pixel size of the first letterbox area (top or left)
int letterboxB = 0; // pixel size of the second letterbox area (bottom or right) int letterboxB = 0; // pixel size of the second letterbox area (bottom or right)
bool horizontal = false; // true = top/bottom letterbox, false = left/right bool horizontal = false; // true = top/bottom letterbox, false = left/right
bool valid = false;
void free() {
::free(edgeA);
::free(edgeB);
edgeA = nullptr;
edgeB = nullptr;
}
}; };
// Binary cache version for edge data files // Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255.
constexpr uint8_t EDGE_CACHE_VERSION = 1; uint8_t snapToEinkLevel(uint8_t gray) {
// Thresholds at midpoints: 42, 127, 212
if (gray < 43) return 0;
if (gray < 128) return 85;
if (gray < 213) return 170;
return 255;
}
// Load cached edge data from a binary file. Returns true if the cache was valid and loaded successfully. // 4x4 Bayer ordered dithering matrix, values 0-255.
// Validates cache version and screen dimensions to detect stale data. // Produces a structured halftone pattern for 4-level quantization.
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxGradientData& data) { // clang-format off
constexpr uint8_t BAYER_4X4[4][4] = {
{ 0, 128, 32, 160},
{192, 64, 224, 96},
{ 48, 176, 16, 144},
{240, 112, 208, 80}
};
// clang-format on
// Ordered (Bayer) dithering for 4-level e-ink display.
// Maps an 8-bit gray value to a 2-bit level (0-3) using the Bayer matrix
// to produce a structured, repeating halftone pattern.
uint8_t quantizeBayerDither(int gray, int x, int y) {
const int threshold = BAYER_4X4[y & 3][x & 3];
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 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;
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) {
FsFile file; FsFile file;
if (!Storage.openFileForRead("SLP", path, file)) return false; if (!Storage.openFileForRead("SLP", path, file)) return false;
@@ -71,9 +97,8 @@ bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, L
serialization::readPod(file, horizontal); serialization::readPod(file, horizontal);
data.horizontal = (horizontal != 0); data.horizontal = (horizontal != 0);
uint16_t edgeCount; serialization::readPod(file, data.avgA);
serialization::readPod(file, edgeCount); serialization::readPod(file, data.avgB);
data.edgeCount = edgeCount;
int16_t lbA, lbB; int16_t lbA, lbB;
serialization::readPod(file, lbA); serialization::readPod(file, lbA);
@@ -81,34 +106,15 @@ bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, L
data.letterboxA = lbA; data.letterboxA = lbA;
data.letterboxB = lbB; data.letterboxB = lbB;
if (edgeCount == 0 || edgeCount > 2048) {
file.close();
return false;
}
data.edgeA = static_cast<uint8_t*>(malloc(edgeCount));
data.edgeB = static_cast<uint8_t*>(malloc(edgeCount));
if (!data.edgeA || !data.edgeB) {
data.free();
file.close();
return false;
}
if (file.read(data.edgeA, edgeCount) != static_cast<int>(edgeCount) ||
file.read(data.edgeB, edgeCount) != static_cast<int>(edgeCount)) {
data.free();
file.close();
return false;
}
file.close(); file.close();
Serial.printf("[%lu] [SLP] Loaded edge cache from %s (%d edges)\n", millis(), path.c_str(), edgeCount); data.valid = true;
Serial.printf("[%lu] [SLP] Loaded edge cache from %s (avgA=%d, avgB=%d)\n", millis(), path.c_str(), data.avgA,
data.avgB);
return true; return true;
} }
// Save edge data to a binary cache file for reuse on subsequent sleep screens. bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxGradientData& data) { if (!data.valid) return false;
if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return false;
FsFile file; FsFile file;
if (!Storage.openFileForWrite("SLP", path, file)) return false; if (!Storage.openFileForWrite("SLP", path, file)) return false;
@@ -117,23 +123,22 @@ bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, c
serialization::writePod(file, static_cast<uint16_t>(screenWidth)); serialization::writePod(file, static_cast<uint16_t>(screenWidth));
serialization::writePod(file, static_cast<uint16_t>(screenHeight)); serialization::writePod(file, static_cast<uint16_t>(screenHeight));
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0)); serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
serialization::writePod(file, static_cast<uint16_t>(data.edgeCount)); serialization::writePod(file, data.avgA);
serialization::writePod(file, data.avgB);
serialization::writePod(file, static_cast<int16_t>(data.letterboxA)); serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
serialization::writePod(file, static_cast<int16_t>(data.letterboxB)); serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
file.write(data.edgeA, data.edgeCount);
file.write(data.edgeB, data.edgeCount);
file.close(); file.close();
Serial.printf("[%lu] [SLP] Saved edge cache to %s (%d edges)\n", millis(), path.c_str(), data.edgeCount); Serial.printf("[%lu] [SLP] Saved edge cache to %s\n", millis(), path.c_str());
return true; return true;
} }
// Read the bitmap once to sample the first/last EDGE_SAMPLE_DEPTH rows or columns. // Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges.
// Returns edge color arrays in source pixel resolution. Caller must call data.free() when done. // Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers.
// After sampling the bitmap is rewound via rewindToData(). // After sampling the bitmap is rewound via rewindToData().
LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight, LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
float scale, float cropX, float cropY) { float scale, float cropX, float cropY) {
LetterboxGradientData data; LetterboxFillData data;
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f)); const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f)); const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
@@ -146,35 +151,22 @@ LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize)); auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) { if (!outputRow || !rowBytes) {
::free(outputRow); free(outputRow);
::free(rowBytes); free(rowBytes);
return data; return data;
} }
if (imgY > 0) { if (imgY > 0) {
// Top/bottom letterboxing -- sample per-column averages of first/last N rows // Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows
data.horizontal = true; data.horizontal = true;
data.edgeCount = visibleWidth;
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale)); const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
data.letterboxA = imgY; data.letterboxA = imgY;
data.letterboxB = pageHeight - imgY - scaledHeight; data.letterboxB = pageHeight - imgY - scaledHeight;
if (data.letterboxB < 0) data.letterboxB = 0; if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight); const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
uint64_t sumTop = 0, sumBot = 0;
auto* accumTop = static_cast<uint32_t*>(calloc(visibleWidth, sizeof(uint32_t))); int countTop = 0, countBot = 0;
auto* accumBot = static_cast<uint32_t*>(calloc(visibleWidth, sizeof(uint32_t)));
data.edgeA = static_cast<uint8_t*>(malloc(visibleWidth));
data.edgeB = static_cast<uint8_t*>(malloc(visibleWidth));
if (!accumTop || !accumBot || !data.edgeA || !data.edgeB) {
::free(accumTop);
::free(accumBot);
data.free();
::free(outputRow);
::free(rowBytes);
return data;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
@@ -187,188 +179,106 @@ LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY
if (!inTop && !inBot) continue; if (!inTop && !inBot) continue;
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const int outX = bmpX - cropPixX;
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
const uint8_t gray = val2bitToGray(val); const uint8_t gray = val2bitToGray(val);
if (inTop) accumTop[outX] += gray; if (inTop) {
if (inBot) accumBot[outX] += gray; sumTop += gray;
countTop++;
}
if (inBot) {
sumBot += gray;
countBot++;
}
} }
} }
for (int i = 0; i < visibleWidth; i++) { data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
data.edgeA[i] = static_cast<uint8_t>(accumTop[i] / sampleRows); data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
data.edgeB[i] = static_cast<uint8_t>(accumBot[i] / sampleRows); data.valid = true;
}
::free(accumTop);
::free(accumBot);
} else if (imgX > 0) { } else if (imgX > 0) {
// Left/right letterboxing -- sample per-row averages of first/last N columns // Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
data.horizontal = false; data.horizontal = false;
data.edgeCount = visibleHeight;
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale)); const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
data.letterboxA = imgX; data.letterboxA = imgX;
data.letterboxB = pageWidth - imgX - scaledWidth; data.letterboxB = pageWidth - imgX - scaledWidth;
if (data.letterboxB < 0) data.letterboxB = 0; if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth); const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
uint64_t sumLeft = 0, sumRight = 0;
auto* accumLeft = static_cast<uint32_t*>(calloc(visibleHeight, sizeof(uint32_t))); int countLeft = 0, countRight = 0;
auto* accumRight = static_cast<uint32_t*>(calloc(visibleHeight, sizeof(uint32_t)));
data.edgeA = static_cast<uint8_t*>(malloc(visibleHeight));
data.edgeB = static_cast<uint8_t*>(malloc(visibleHeight));
if (!accumLeft || !accumRight || !data.edgeA || !data.edgeB) {
::free(accumLeft);
::free(accumRight);
data.free();
::free(outputRow);
::free(rowBytes);
return data;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
const int outY = logicalY - cropPixY;
// Sample left edge columns
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) { for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
accumLeft[outY] += val2bitToGray(val); sumLeft += val2bitToGray(val);
countLeft++;
} }
// Sample right edge columns
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
accumRight[outY] += val2bitToGray(val); sumRight += val2bitToGray(val);
countRight++;
} }
} }
for (int i = 0; i < visibleHeight; i++) { data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
data.edgeA[i] = static_cast<uint8_t>(accumLeft[i] / sampleCols); data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
data.edgeB[i] = static_cast<uint8_t>(accumRight[i] / sampleCols); data.valid = true;
}
::free(accumLeft);
::free(accumRight);
} }
::free(outputRow); free(outputRow);
::free(rowBytes); free(rowBytes);
bitmap.rewindToData(); bitmap.rewindToData();
return data; return data;
} }
// Draw dithered fills in the letterbox areas using the sampled edge colors. // Draw letterbox fill in the areas around the cover image.
// fillMode selects the fill algorithm: SOLID (single dominant shade), BLENDED (per-pixel edge color), // DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color.
// or GRADIENT (per-pixel edge color interpolated toward targetColor). // SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill.
// targetColor is the color the gradient fades toward (255=white, 0=black); only used in GRADIENT mode.
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB). // Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxGradientData& data, float scale, uint8_t fillMode, void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
int targetColor) { if (!data.valid) return;
if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return;
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID); const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
const bool isGradient = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_GRADIENT);
// For SOLID mode, compute the dominant (average) shade for each edge once // For SOLID: snap to nearest e-ink level then convert to 2-bit
uint8_t solidColorA = 0, solidColorB = 0; // For DITHERED: use the raw average, Bayer dithering produces the 2-bit level per pixel
if (isSolid) { const uint8_t colorA = isSolid ? snapToEinkLevel(data.avgA) : data.avgA;
uint32_t sumA = 0, sumB = 0; const uint8_t colorB = isSolid ? snapToEinkLevel(data.avgB) : data.avgB;
for (int i = 0; i < data.edgeCount; i++) { const uint8_t solidA = colorA / 85; // only used for SOLID
sumA += data.edgeA[i]; const uint8_t solidB = colorB / 85;
sumB += data.edgeB[i];
}
solidColorA = static_cast<uint8_t>(sumA / data.edgeCount);
solidColorB = static_cast<uint8_t>(sumB / data.edgeCount);
}
// Helper: compute gray value for a pixel given the edge color and interpolation factor t (0..1)
// GRADIENT interpolates from edgeColor toward targetColor; SOLID and BLENDED return edgeColor directly.
auto computeGray = [&](int edgeColor, float t) -> int {
if (isGradient) return edgeColor + static_cast<int>(static_cast<float>(targetColor - edgeColor) * t);
return edgeColor;
};
if (data.horizontal) { if (data.horizontal) {
// Top letterbox // Top letterbox
if (data.letterboxA > 0) { if (data.letterboxA > 0) {
const int imgTopY = data.letterboxA; for (int y = 0; y < data.letterboxA; y++)
for (int screenY = 0; screenY < imgTopY; screenY++) { for (int x = 0; x < renderer.getScreenWidth(); x++)
const float t = static_cast<float>(imgTopY - screenY) / static_cast<float>(imgTopY); renderer.drawPixelGray(x, y, isSolid ? solidA : quantizeBayerDither(colorA, x, y));
for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) {
int edgeColor;
if (isSolid) {
edgeColor = solidColorA;
} else {
int srcCol = static_cast<int>(screenX / scale);
srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1));
edgeColor = data.edgeA[srcCol];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
} }
// Bottom letterbox // Bottom letterbox
if (data.letterboxB > 0) { if (data.letterboxB > 0) {
const int imgBottomY = renderer.getScreenHeight() - data.letterboxB; const int start = renderer.getScreenHeight() - data.letterboxB;
for (int screenY = imgBottomY; screenY < renderer.getScreenHeight(); screenY++) { for (int y = start; y < renderer.getScreenHeight(); y++)
const float t = static_cast<float>(screenY - imgBottomY + 1) / static_cast<float>(data.letterboxB); for (int x = 0; x < renderer.getScreenWidth(); x++)
for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) { renderer.drawPixelGray(x, y, isSolid ? solidB : quantizeBayerDither(colorB, x, y));
int edgeColor;
if (isSolid) {
edgeColor = solidColorB;
} else {
int srcCol = static_cast<int>(screenX / scale);
srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1));
edgeColor = data.edgeB[srcCol];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
} }
} else { } else {
// Left letterbox // Left letterbox
if (data.letterboxA > 0) { if (data.letterboxA > 0) {
const int imgLeftX = data.letterboxA; for (int x = 0; x < data.letterboxA; x++)
for (int screenX = 0; screenX < imgLeftX; screenX++) { for (int y = 0; y < renderer.getScreenHeight(); y++)
const float t = static_cast<float>(imgLeftX - screenX) / static_cast<float>(imgLeftX); renderer.drawPixelGray(x, y, isSolid ? solidA : quantizeBayerDither(colorA, x, y));
for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) {
int edgeColor;
if (isSolid) {
edgeColor = solidColorA;
} else {
int srcRow = static_cast<int>(screenY / scale);
srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1));
edgeColor = data.edgeA[srcRow];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
} }
// Right letterbox // Right letterbox
if (data.letterboxB > 0) { if (data.letterboxB > 0) {
const int imgRightX = renderer.getScreenWidth() - data.letterboxB; const int start = renderer.getScreenWidth() - data.letterboxB;
for (int screenX = imgRightX; screenX < renderer.getScreenWidth(); screenX++) { for (int x = start; x < renderer.getScreenWidth(); x++)
const float t = static_cast<float>(screenX - imgRightX + 1) / static_cast<float>(data.letterboxB); for (int y = 0; y < renderer.getScreenHeight(); y++)
for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) { renderer.drawPixelGray(x, y, isSolid ? solidB : quantizeBayerDither(colorB, x, y));
int edgeColor;
if (isSolid) {
edgeColor = solidColorB;
} else {
int srcRow = static_cast<int>(screenY / scale);
srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1));
edgeColor = data.edgeB[srcRow];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
} }
} }
} }
@@ -530,28 +440,31 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
// Determine letterbox fill settings // Determine letterbox fill settings
const uint8_t fillMode = SETTINGS.sleepScreenLetterboxFill; const uint8_t fillMode = SETTINGS.sleepScreenLetterboxFill;
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE); const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
const int targetColor =
(SETTINGS.sleepScreenGradientDir == CrossPointSettings::SLEEP_SCREEN_GRADIENT_DIR::GRADIENT_TO_BLACK) ? 0 : 255;
static const char* fillModeNames[] = {"none", "solid", "blended", "gradient"}; static const char* fillModeNames[] = {"dithered", "solid", "none"};
const char* fillModeName = (fillMode < 4) ? fillModeNames[fillMode] : "unknown"; const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown";
// Load cached edge data or sample from bitmap (first pass over bitmap, then rewind) // Compute edge averages if letterbox fill is requested (try cache first)
LetterboxGradientData gradientData; LetterboxFillData fillData;
const bool hasLetterbox = (x > 0 || y > 0); const bool hasLetterbox = (x > 0 || y > 0);
if (hasLetterbox && wantFill) { if (hasLetterbox && wantFill) {
bool cacheLoaded = false; bool cacheLoaded = false;
if (!edgeCachePath.empty()) { if (!edgeCachePath.empty()) {
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData); cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
} }
if (!cacheLoaded) { if (!cacheLoaded) {
Serial.printf("[%lu] [SLP] Letterbox detected (x=%d, y=%d), sampling edges for %s fill\n", millis(), x, y, Serial.printf("[%lu] [SLP] Letterbox detected (x=%d, y=%d), computing edge averages for %s fill\n", millis(), x,
fillModeName); y, fillModeName);
gradientData = sampleBitmapEdges(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY); fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
if (!edgeCachePath.empty() && gradientData.edgeA) { if (fillData.valid && !edgeCachePath.empty()) {
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData); saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
} }
} }
if (fillData.valid) {
Serial.printf("[%lu] [SLP] Letterbox fill: %s, horizontal=%d, avgA=%d, avgB=%d, letterboxA=%d, letterboxB=%d\n",
millis(), fillModeName, fillData.horizontal, fillData.avgA, fillData.avgB, fillData.letterboxA,
fillData.letterboxB);
}
} }
renderer.clearScreen(); renderer.clearScreen();
@@ -560,8 +473,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Draw letterbox fill (BW pass) // Draw letterbox fill (BW pass)
if (gradientData.edgeA) { if (fillData.valid) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); drawLetterboxFill(renderer, fillData, fillMode);
} }
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
@@ -576,8 +489,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (gradientData.edgeA) { if (fillData.valid) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); 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();
@@ -585,8 +498,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (gradientData.edgeA) { if (fillData.valid) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); 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();
@@ -594,8 +507,6 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
renderer.displayGrayBuffer(); renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW); renderer.setRenderMode(GfxRenderer::BW);
} }
gradientData.free();
} }
void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderCoverSleepScreen() const {
@@ -665,17 +576,17 @@ void SleepActivity::renderCoverSleepScreen() const {
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
std::string edgeCachePath;
if (coverBmpPath.size() > 4) {
edgeCachePath = coverBmpPath.substr(0, coverBmpPath.size() - 4) + "_edges.bin";
}
FsFile file; FsFile file;
if (Storage.openFileForRead("SLP", coverBmpPath, file)) { if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Rendering sleep cover: %s\n", millis(), coverBmpPath.c_str()); Serial.printf("[%lu] [SLP] Rendering sleep cover: %s\n", millis(), coverBmpPath.c_str());
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
std::string edgeCachePath;
const auto dotPos = coverBmpPath.rfind(".bmp");
if (dotPos != std::string::npos) {
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
}
renderBitmapSleepScreen(bitmap, edgeCachePath); renderBitmapSleepScreen(bitmap, edgeCachePath);
return; return;
} }

View File

@@ -1,4 +1,5 @@
#pragma once #pragma once
#include <string> #include <string>
#include "../Activity.h" #include "../Activity.h"