sleep screen filled with cover image's perimeter dominant color prior to being drawn
This commit is contained in:
parent
1e20d30875
commit
2f21f55512
@ -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;
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user