diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index ad25ffc..63535ac 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -262,3 +262,90 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::Ok; } + +bool Bitmap::detectPerimeterIsBlack() const { + // Detect if the 1-pixel perimeter of the image is mostly black or white. + // Returns true if mostly black (luminance < 128), false if mostly white. + + if (width <= 0 || height <= 0) return false; + + auto* rowBuffer = static_cast(malloc(rowBytes)); + if (!rowBuffer) return false; + + int blackCount = 0; + int whiteCount = 0; + + // Helper lambda to get luminance from a pixel at position x in rowBuffer + auto getLuminance = [&](int x) -> uint8_t { + switch (bpp) { + case 32: { + const uint8_t* p = rowBuffer + x * 4; + return (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + } + case 24: { + const uint8_t* p = rowBuffer + x * 3; + return (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + } + case 8: + return paletteLum[rowBuffer[x]]; + case 2: + return paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; + case 1: { + const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0; + return paletteLum[palIndex]; + } + default: + return 128; // Neutral if unsupported + } + }; + + // Helper to classify and count a pixel + auto countPixel = [&](int x) { + const uint8_t lum = getLuminance(x); + if (lum < 128) { + blackCount++; + } else { + whiteCount++; + } + }; + + // Helper to seek to a specific image row (accounting for top-down vs bottom-up) + auto seekToRow = [&](int imageRow) -> bool { + // In bottom-up BMP (topDown=false), row 0 in file is the bottom row of image + // In top-down BMP (topDown=true), row 0 in file is the top row of image + int fileRow = topDown ? imageRow : (height - 1 - imageRow); + return file.seek(bfOffBits + static_cast(fileRow) * rowBytes); + }; + + // Sample top row (image row 0) - all pixels + if (seekToRow(0) && file.read(rowBuffer, rowBytes) == rowBytes) { + for (int x = 0; x < width; x++) { + countPixel(x); + } + } + + // Sample bottom row (image row height-1) - all pixels + if (height > 1) { + if (seekToRow(height - 1) && file.read(rowBuffer, rowBytes) == rowBytes) { + for (int x = 0; x < width; x++) { + countPixel(x); + } + } + } + + // Sample left and right edges from intermediate rows + for (int y = 1; y < height - 1; y++) { + if (seekToRow(y) && file.read(rowBuffer, rowBytes) == rowBytes) { + countPixel(0); // Left edge + countPixel(width - 1); // Right edge + } + } + + free(rowBuffer); + + // Rewind file position for subsequent drawing + rewindToData(); + + // Return true if perimeter is mostly black + return blackCount > whiteCount; +} diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 544869c..56cfe3e 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -37,6 +37,7 @@ class Bitmap { BmpReaderError parseHeaders(); BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError rewindToData() const; + bool detectPerimeterIsBlack() const; int getWidth() const { return width; } int getHeight() const { return height; } bool isTopDown() const { return topDown; } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 91d75ab..8411294 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -12,6 +12,13 @@ #include "images/CrossLarge.h" #include "util/StringUtils.h" +namespace { +// Perimeter cache file format: +// - 4 bytes: uint32_t file size (for cache invalidation) +// - 1 byte: result (0 = white perimeter, 1 = black perimeter) +constexpr size_t PERIM_CACHE_SIZE = 5; +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); renderPopup("Entering Sleep..."); @@ -88,14 +95,14 @@ void SleepActivity::renderCustomSleepScreen() const { } APP_STATE.lastSleepImage = randomFileIndex; APP_STATE.saveToFile(); - const auto filename = "/sleep/" + files[randomFileIndex]; + const auto bmpPath = "/sleep/" + files[randomFileIndex]; FsFile file; - if (SdMan.openFileForRead("SLP", filename, file)) { + if (SdMan.openFileForRead("SLP", bmpPath, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - renderBitmapSleepScreen(bitmap); + renderBitmapSleepScreen(bitmap, bmpPath); dir.close(); return; } @@ -107,11 +114,12 @@ void SleepActivity::renderCustomSleepScreen() const { // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. FsFile file; - if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { + const std::string rootSleepPath = "/sleep.bmp"; + if (SdMan.openFileForRead("SLP", rootSleepPath, file)) { Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); - renderBitmapSleepScreen(bitmap); + renderBitmapSleepScreen(bitmap, rootSleepPath); return; } } @@ -136,13 +144,15 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } -void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { +void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); float cropX = 0, cropY = 0; int drawWidth = pageWidth; int drawHeight = pageHeight; + int fillWidth = pageWidth; // Actual area the image will occupy + int fillHeight = pageHeight; Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight); @@ -160,6 +170,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { // Don't constrain to screen dimensions - drawBitmap will clip drawWidth = 0; drawHeight = 0; + fillWidth = bitmap.getWidth(); + fillHeight = bitmap.getHeight(); Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d\n", millis(), x, y); } else if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { // CROP mode: Scale to fill screen completely (may crop edges) @@ -176,6 +188,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { // After cropping, the image should fill the screen exactly x = 0; y = 0; + fillWidth = pageWidth; + fillHeight = pageHeight; Serial.printf("[%lu] [SLP] CROP mode: drawing at 0, 0 with crop %f, %f\n", millis(), cropX, cropY); } else { // FIT mode (default): Scale to fit entire image within screen (may have letterboxing) @@ -188,18 +202,28 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { // Image is taller than screen ratio - fit to height scale = static_cast(pageHeight) / static_cast(bitmap.getHeight()); } - const int scaledWidth = static_cast(bitmap.getWidth() * scale); - const int scaledHeight = static_cast(bitmap.getHeight() * scale); + fillWidth = static_cast(bitmap.getWidth() * scale); + fillHeight = static_cast(bitmap.getHeight() * scale); // Center the scaled image - x = (pageWidth - scaledWidth) / 2; - y = (pageHeight - scaledHeight) / 2; + x = (pageWidth - fillWidth) / 2; + y = (pageHeight - fillHeight) / 2; Serial.printf("[%lu] [SLP] FIT mode: scale %f, scaled size %d x %d, position %d, %d\n", millis(), scale, - scaledWidth, scaledHeight, x, y); + fillWidth, fillHeight, x, y); } + // Detect perimeter color and clear to matching background + const bool isBlackPerimeter = getPerimeterIsBlack(bitmap, bmpPath); + const uint8_t clearColor = isBlackPerimeter ? 0x00 : 0xFF; + Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); - renderer.clearScreen(); + renderer.clearScreen(clearColor); + + // If background is black, fill the image area with white first so white pixels render correctly + if (isBlackPerimeter) { + renderer.fillRect(x, y, fillWidth, fillHeight, false); // false = white + } + renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY); renderer.displayBuffer(EInkDisplay::HALF_REFRESH); @@ -281,7 +305,7 @@ void SleepActivity::renderCoverSleepScreen() const { if (SdMan.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - renderBitmapSleepScreen(bitmap); + renderBitmapSleepScreen(bitmap, coverBmpPath); return; } } @@ -293,3 +317,80 @@ void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } + +std::string SleepActivity::getPerimeterCachePath(const std::string& bmpPath) { + // Convert "/dir/file.bmp" to "/dir/.file.bmp.perim" + const size_t lastSlash = bmpPath.find_last_of('/'); + if (lastSlash == std::string::npos) { + // No directory, just prepend dot + return "." + bmpPath + ".perim"; + } + const std::string dir = bmpPath.substr(0, lastSlash + 1); + const std::string filename = bmpPath.substr(lastSlash + 1); + return dir + "." + filename + ".perim"; +} + +bool SleepActivity::getPerimeterIsBlack(const Bitmap& bitmap, const std::string& bmpPath) const { + const std::string cachePath = getPerimeterCachePath(bmpPath); + + // Try to read from cache + FsFile cacheFile; + if (SdMan.openFileForRead("SLP", cachePath, cacheFile)) { + uint8_t cacheData[PERIM_CACHE_SIZE]; + if (cacheFile.read(cacheData, PERIM_CACHE_SIZE) == PERIM_CACHE_SIZE) { + // Extract cached file size + const uint32_t cachedSize = static_cast(cacheData[0]) | + (static_cast(cacheData[1]) << 8) | + (static_cast(cacheData[2]) << 16) | + (static_cast(cacheData[3]) << 24); + + // Get current BMP file size + FsFile bmpFile; + uint32_t currentSize = 0; + if (SdMan.openFileForRead("SLP", bmpPath, bmpFile)) { + currentSize = bmpFile.size(); + bmpFile.close(); + } + + // Validate cache + if (cachedSize == currentSize && currentSize > 0) { + const bool result = cacheData[4] != 0; + Serial.printf("[%lu] [SLP] Perimeter cache hit for %s: %s\n", millis(), bmpPath.c_str(), + result ? "black" : "white"); + cacheFile.close(); + return result; + } + Serial.printf("[%lu] [SLP] Perimeter cache invalid (size mismatch: %lu vs %lu)\n", millis(), + static_cast(cachedSize), static_cast(currentSize)); + } + cacheFile.close(); + } + + // Cache miss - calculate perimeter + Serial.printf("[%lu] [SLP] Calculating perimeter for %s\n", millis(), bmpPath.c_str()); + const bool isBlack = bitmap.detectPerimeterIsBlack(); + Serial.printf("[%lu] [SLP] Perimeter detected: %s\n", millis(), isBlack ? "black" : "white"); + + // Get BMP file size for cache + FsFile bmpFile; + uint32_t fileSize = 0; + if (SdMan.openFileForRead("SLP", bmpPath, bmpFile)) { + fileSize = bmpFile.size(); + bmpFile.close(); + } + + // Save to cache + if (fileSize > 0 && SdMan.openFileForWrite("SLP", cachePath, cacheFile)) { + uint8_t cacheData[PERIM_CACHE_SIZE]; + cacheData[0] = fileSize & 0xFF; + cacheData[1] = (fileSize >> 8) & 0xFF; + cacheData[2] = (fileSize >> 16) & 0xFF; + cacheData[3] = (fileSize >> 24) & 0xFF; + cacheData[4] = isBlack ? 1 : 0; + cacheFile.write(cacheData, PERIM_CACHE_SIZE); + cacheFile.close(); + Serial.printf("[%lu] [SLP] Saved perimeter cache to %s\n", millis(), cachePath.c_str()); + } + + return isBlack; +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 283220c..5bcb1d0 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -1,6 +1,8 @@ #pragma once #include "../Activity.h" +#include + class Bitmap; class SleepActivity final : public Activity { @@ -14,6 +16,10 @@ class SleepActivity final : public Activity { void renderDefaultSleepScreen() const; void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; - void renderBitmapSleepScreen(const Bitmap& bitmap) const; + void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const; void renderBlankSleepScreen() const; + + // Perimeter detection caching helpers + static std::string getPerimeterCachePath(const std::string& bmpPath); + bool getPerimeterIsBlack(const Bitmap& bitmap, const std::string& bmpPath) const; };