sleep screen filled with cover image's perimeter dominant color prior to being drawn

This commit is contained in:
cottongin 2026-01-24 03:29:34 -05:00
parent 1e20d30875
commit 2f21f55512
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
4 changed files with 209 additions and 14 deletions

View File

@ -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<uint8_t*>(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<uint32_t>(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;
}

View File

@ -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; }

View File

@ -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<float>(pageHeight) / static_cast<float>(bitmap.getHeight());
}
const int scaledWidth = static_cast<int>(bitmap.getWidth() * scale);
const int scaledHeight = static_cast<int>(bitmap.getHeight() * scale);
fillWidth = static_cast<int>(bitmap.getWidth() * scale);
fillHeight = static_cast<int>(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<uint32_t>(cacheData[0]) |
(static_cast<uint32_t>(cacheData[1]) << 8) |
(static_cast<uint32_t>(cacheData[2]) << 16) |
(static_cast<uint32_t>(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<unsigned long>(cachedSize), static_cast<unsigned long>(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;
}

View File

@ -1,6 +1,8 @@
#pragma once
#include "../Activity.h"
#include <string>
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;
};