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;
|
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 parseHeaders();
|
||||||
BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const;
|
BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const;
|
||||||
BmpReaderError rewindToData() const;
|
BmpReaderError rewindToData() const;
|
||||||
|
bool detectPerimeterIsBlack() const;
|
||||||
int getWidth() const { return width; }
|
int getWidth() const { return width; }
|
||||||
int getHeight() const { return height; }
|
int getHeight() const { return height; }
|
||||||
bool isTopDown() const { return topDown; }
|
bool isTopDown() const { return topDown; }
|
||||||
|
|||||||
@ -12,6 +12,13 @@
|
|||||||
#include "images/CrossLarge.h"
|
#include "images/CrossLarge.h"
|
||||||
#include "util/StringUtils.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() {
|
void SleepActivity::onEnter() {
|
||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
renderPopup("Entering Sleep...");
|
renderPopup("Entering Sleep...");
|
||||||
@ -88,14 +95,14 @@ void SleepActivity::renderCustomSleepScreen() const {
|
|||||||
}
|
}
|
||||||
APP_STATE.lastSleepImage = randomFileIndex;
|
APP_STATE.lastSleepImage = randomFileIndex;
|
||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
const auto filename = "/sleep/" + files[randomFileIndex];
|
const auto bmpPath = "/sleep/" + files[randomFileIndex];
|
||||||
FsFile file;
|
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());
|
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
|
||||||
delay(100);
|
delay(100);
|
||||||
Bitmap bitmap(file, true);
|
Bitmap bitmap(file, true);
|
||||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||||
renderBitmapSleepScreen(bitmap);
|
renderBitmapSleepScreen(bitmap, bmpPath);
|
||||||
dir.close();
|
dir.close();
|
||||||
return;
|
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
|
// 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.
|
// render a custom sleep screen instead of the default.
|
||||||
FsFile file;
|
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);
|
Bitmap bitmap(file, true);
|
||||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||||
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
|
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
|
||||||
renderBitmapSleepScreen(bitmap);
|
renderBitmapSleepScreen(bitmap, rootSleepPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -136,13 +144,15 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
|||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
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;
|
int x, y;
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
float cropX = 0, cropY = 0;
|
float cropX = 0, cropY = 0;
|
||||||
int drawWidth = pageWidth;
|
int drawWidth = pageWidth;
|
||||||
int drawHeight = pageHeight;
|
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(),
|
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
|
||||||
pageWidth, pageHeight);
|
pageWidth, pageHeight);
|
||||||
@ -160,6 +170,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
|||||||
// Don't constrain to screen dimensions - drawBitmap will clip
|
// Don't constrain to screen dimensions - drawBitmap will clip
|
||||||
drawWidth = 0;
|
drawWidth = 0;
|
||||||
drawHeight = 0;
|
drawHeight = 0;
|
||||||
|
fillWidth = bitmap.getWidth();
|
||||||
|
fillHeight = bitmap.getHeight();
|
||||||
Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d\n", millis(), x, y);
|
Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d\n", millis(), x, y);
|
||||||
} else if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
} else if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||||
// CROP mode: Scale to fill screen completely (may crop edges)
|
// 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
|
// After cropping, the image should fill the screen exactly
|
||||||
x = 0;
|
x = 0;
|
||||||
y = 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);
|
Serial.printf("[%lu] [SLP] CROP mode: drawing at 0, 0 with crop %f, %f\n", millis(), cropX, cropY);
|
||||||
} else {
|
} else {
|
||||||
// FIT mode (default): Scale to fit entire image within screen (may have letterboxing)
|
// 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
|
// Image is taller than screen ratio - fit to height
|
||||||
scale = static_cast<float>(pageHeight) / static_cast<float>(bitmap.getHeight());
|
scale = static_cast<float>(pageHeight) / static_cast<float>(bitmap.getHeight());
|
||||||
}
|
}
|
||||||
const int scaledWidth = static_cast<int>(bitmap.getWidth() * scale);
|
fillWidth = static_cast<int>(bitmap.getWidth() * scale);
|
||||||
const int scaledHeight = static_cast<int>(bitmap.getHeight() * scale);
|
fillHeight = static_cast<int>(bitmap.getHeight() * scale);
|
||||||
|
|
||||||
// Center the scaled image
|
// Center the scaled image
|
||||||
x = (pageWidth - scaledWidth) / 2;
|
x = (pageWidth - fillWidth) / 2;
|
||||||
y = (pageHeight - scaledHeight) / 2;
|
y = (pageHeight - fillHeight) / 2;
|
||||||
Serial.printf("[%lu] [SLP] FIT mode: scale %f, scaled size %d x %d, position %d, %d\n", millis(), scale,
|
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);
|
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.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY);
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
|
|
||||||
@ -281,7 +305,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
|
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
|
||||||
Bitmap bitmap(file);
|
Bitmap bitmap(file);
|
||||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||||
renderBitmapSleepScreen(bitmap);
|
renderBitmapSleepScreen(bitmap, coverBmpPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -293,3 +317,80 @@ void SleepActivity::renderBlankSleepScreen() const {
|
|||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
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
|
#pragma once
|
||||||
#include "../Activity.h"
|
#include "../Activity.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
class Bitmap;
|
class Bitmap;
|
||||||
|
|
||||||
class SleepActivity final : public Activity {
|
class SleepActivity final : public Activity {
|
||||||
@ -14,6 +16,10 @@ class SleepActivity final : public Activity {
|
|||||||
void renderDefaultSleepScreen() const;
|
void renderDefaultSleepScreen() const;
|
||||||
void renderCustomSleepScreen() const;
|
void renderCustomSleepScreen() const;
|
||||||
void renderCoverSleepScreen() const;
|
void renderCoverSleepScreen() const;
|
||||||
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
|
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const;
|
||||||
void renderBlankSleepScreen() 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