From afe967215669f1ff9eee427bb045d11c970dd7c7 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Mon, 5 Jan 2026 11:07:27 +0100 Subject: [PATCH] Feature/cover crop mode (#225) Added a setting to select `fit` or `crop` for cover image on sleep screen. Might add a `expand` feature in the future that does not crop but rather fills the blank space with a mirror of the image. --------- Co-authored-by: Dave Allie --- lib/GfxRenderer/Bitmap.cpp | 17 ++++----- lib/GfxRenderer/Bitmap.h | 4 +-- lib/GfxRenderer/GfxRenderer.cpp | 35 +++++++++++++------ lib/GfxRenderer/GfxRenderer.h | 3 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 4 ++- src/CrossPointSettings.cpp | 9 ++--- src/CrossPointSettings.h | 3 ++ src/activities/boot_sleep/SleepActivity.cpp | 29 +++++++++++---- src/activities/settings/SettingsActivity.cpp | 3 +- 9 files changed, 70 insertions(+), 37 deletions(-) diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 7c46df1c..8cc8a5f3 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -250,34 +250,29 @@ BmpReaderError Bitmap::parseHeaders() { delete[] errorNextRow; errorCurRow = new int16_t[width + 2](); // +2 for boundary handling errorNextRow = new int16_t[width + 2](); - lastRowY = -1; + prevRowY = -1; } return BmpReaderError::Ok; } // packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white -BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const { +BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; // Handle Floyd-Steinberg error buffer progression const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow; if (useFS) { - // Check if we need to advance to next row (or reset if jumping) - if (rowY != lastRowY + 1 && rowY != 0) { - // Non-sequential row access - reset error buffers - memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - } else if (rowY > 0) { + if (prevRowY != -1) { // Sequential access - swap buffers int16_t* temp = errorCurRow; errorCurRow = errorNextRow; errorNextRow = temp; memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); } - lastRowY = rowY; } + prevRowY += 1; uint8_t* outPtr = data; uint8_t currentOutByte = 0; @@ -292,7 +287,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); } else { // Simple quantization or noise dithering - color = quantize(lum, currentX, rowY); + color = quantize(lum, currentX, prevRowY); } currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -365,7 +360,7 @@ BmpReaderError Bitmap::rewindToData() const { if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - lastRowY = -1; + prevRowY = -1; } return BmpReaderError::Ok; diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 7e799647..a3f2e00c 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -31,7 +31,7 @@ class Bitmap { explicit Bitmap(FsFile& file) : file(file) {} ~Bitmap(); BmpReaderError parseHeaders(); - BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const; + BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError rewindToData() const; int getWidth() const { return width; } int getHeight() const { return height; } @@ -55,5 +55,5 @@ class Bitmap { // Floyd-Steinberg dithering state (mutable for const methods) mutable int16_t* errorCurRow = nullptr; mutable int16_t* errorNextRow = nullptr; - mutable int lastRowY = -1; // Track row progression for error propagation + mutable int prevRowY = -1; // Track row progression for error propagation }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 52cd6f4d..cc1288a7 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -152,18 +152,24 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); } -void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, - const int maxHeight) const { +void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, + const float cropX, const float cropY) const { float scale = 1.0f; bool isScaled = false; - if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { - scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); + int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f); + Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(), + cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up"); + + if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) { + scale = static_cast(maxWidth) / static_cast((1.0f - cropX) * bitmap.getWidth()); isScaled = true; } - if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { - scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) { + scale = std::min(scale, static_cast(maxHeight) / static_cast((1.0f - cropY) * bitmap.getHeight())); isScaled = true; } + Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled"); // Calculate output row size (2 bits per pixel, packed into bytes) // IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide @@ -178,29 +184,36 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con return; } - for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) { // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // Screen's (0, 0) is the top-left corner. - int screenY = y + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); + int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); if (isScaled) { screenY = std::floor(screenY * scale); } + screenY += y; // the offset should not be scaled if (screenY >= getScreenHeight()) { break; } - if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); free(outputRow); free(rowBytes); return; } - for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { - int screenX = x + bmpX; + if (bmpY < cropPixY) { + // Skip the row if it's outside the crop area + continue; + } + + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { + int screenX = bmpX - cropPixX; if (isScaled) { screenX = std::floor(screenX * scale); } + screenX += x; // the offset should not be scaled if (screenX >= getScreenWidth()) { break; } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index af4f2d50..e3e9558d 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -66,7 +66,8 @@ class GfxRenderer { void drawRect(int x, int y, int width, int height, bool state = true) const; void fillRect(int x, int y, int width, int height, bool state = true) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; - void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; + void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, + float cropY = 0) const; // Text int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 9c61ef0d..8c8db889 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -468,7 +468,9 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { // Calculate scale to fit within target dimensions while maintaining aspect ratio const float scaleToFitWidth = static_cast(TARGET_MAX_WIDTH) / imageInfo.m_width; const float scaleToFitHeight = static_cast(TARGET_MAX_HEIGHT) / imageInfo.m_height; - const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + // We scale to the smaller dimension, so we can potentially crop later. + // TODO: ideally, we already crop here. + const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; outWidth = static_cast(imageInfo.m_width * scale); outHeight = static_cast(imageInfo.m_height * scale); diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 94764b0f..572bac41 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 14; +constexpr uint8_t SETTINGS_COUNT = 15; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -41,7 +41,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepTimeout); serialization::writePod(outputFile, refreshFrequency); serialization::writePod(outputFile, screenMargin); - + serialization::writePod(outputFile, sleepScreenCoverMode); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -96,7 +96,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, screenMargin); if (++settingsRead >= fileSettingsCount) break; - + serialization::readPod(inputFile, sleepScreenCoverMode); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); @@ -212,4 +213,4 @@ int CrossPointSettings::getReaderFontId() const { return OPENDYSLEXIC_14_FONT_ID; } } -} \ No newline at end of file +} diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 2b3f75a3..5394c4e3 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -17,6 +17,7 @@ class CrossPointSettings { // Should match with SettingsActivity text enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 }; + enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1 }; // Status bar display type enum enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 }; @@ -53,6 +54,8 @@ class CrossPointSettings { // Sleep screen settings uint8_t sleepScreen = DARK; + // Sleep screen cover mode settings + uint8_t sleepScreenCoverMode = FIT; // Status bar settings uint8_t statusBar = FULL; // Text rendering settings diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 8ae42ff5..8d8fd791 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -146,20 +146,36 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); + float cropX = 0, cropY = 0; + Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(), + pageWidth, pageHeight); if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { // image will scale, make sure placement is right - const float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio); if (ratio > screenRatio) { // image wider than viewport ratio, scaled down image needs to be centered vertically + if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { + cropX = 1.0f - (screenRatio / ratio); + Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX); + ratio = (1.0f - cropX) * static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + } x = 0; - y = (pageHeight - pageWidth / ratio) / 2; + y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); + Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y); } else { // image taller than viewport ratio, scaled down image needs to be centered horizontally - x = (pageWidth - pageHeight * ratio) / 2; + if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { + cropY = 1.0f - (ratio / screenRatio); + Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY); + ratio = static_cast(bitmap.getWidth()) / ((1.0f - cropY) * static_cast(bitmap.getHeight())); + } + x = std::round((pageWidth - pageHeight * ratio) / 2); y = 0; + Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x); } } else { // center the image @@ -167,21 +183,22 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { y = (pageHeight - bitmap.getHeight()) / 2; } + Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); renderer.clearScreen(); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.displayBuffer(EInkDisplay::HALF_REFRESH); if (bitmap.hasGreyscale()) { bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleLsbBuffers(); bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleMsbBuffers(); renderer.displayGrayBuffer(); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 963318a1..469c7bb3 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -10,10 +10,11 @@ // Define the static settings list namespace { -constexpr int settingsCount = 15; +constexpr int settingsCount = 16; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), + SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn),