A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix BMP rendering gamma/brightness (#302)

1. Refactor Bitmap.cpp/h to expose the options for FloydSteinberg and
brightness/gamma correction at runtime
2. Fine-tune the thresholds for Floyd Steiberg and simple quantization
to better match the display's colors

Turns out that 2 is enough to make the images render properly, so the
brightness boost and gamma adjustment doesn't seem necessary currently
(at least for my test image).

authored by

Jonas Diemer and committed by
GitHub
0165fab5 66b100c6

+371 -414
+31 -137
lib/GfxRenderer/Bitmap.cpp
··· 8 8 // ============================================================================ 9 9 // Note: For cover images, dithering is done in JpegToBmpConverter.cpp 10 10 // This file handles BMP reading - use simple quantization to avoid double-dithering 11 - constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion 12 - constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering 13 - // Brightness adjustments: 14 - constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments 15 - constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true 16 - constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true 11 + constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg 17 12 // ============================================================================ 18 13 19 - // Integer approximation of gamma correction (brightens midtones) 20 - static inline int applyGamma(int gray) { 21 - if (!GAMMA_CORRECTION) return gray; 22 - const int product = gray * 255; 23 - int x = gray; 24 - if (x > 0) { 25 - x = (x + product / x) >> 1; 26 - x = (x + product / x) >> 1; 27 - } 28 - return x > 255 ? 255 : x; 29 - } 30 - 31 - // Simple quantization without dithering - just divide into 4 levels 32 - static inline uint8_t quantizeSimple(int gray) { 33 - if (USE_BRIGHTNESS) { 34 - gray += BRIGHTNESS_BOOST; 35 - if (gray > 255) gray = 255; 36 - gray = applyGamma(gray); 37 - } 38 - return static_cast<uint8_t>(gray >> 6); 39 - } 40 - 41 - // Hash-based noise dithering - survives downsampling without moiré artifacts 42 - static inline uint8_t quantizeNoise(int gray, int x, int y) { 43 - if (USE_BRIGHTNESS) { 44 - gray += BRIGHTNESS_BOOST; 45 - if (gray > 255) gray = 255; 46 - gray = applyGamma(gray); 47 - } 48 - 49 - uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u; 50 - hash = (hash ^ (hash >> 13)) * 1274126177u; 51 - const int threshold = static_cast<int>(hash >> 24); 52 - 53 - const int scaled = gray * 3; 54 - if (scaled < 255) { 55 - return (scaled + threshold >= 255) ? 1 : 0; 56 - } else if (scaled < 510) { 57 - return ((scaled - 255) + threshold >= 255) ? 2 : 1; 58 - } else { 59 - return ((scaled - 510) + threshold >= 255) ? 3 : 2; 60 - } 61 - } 62 - 63 - // Main quantization function 64 - static inline uint8_t quantize(int gray, int x, int y) { 65 - if (USE_NOISE_DITHERING) { 66 - return quantizeNoise(gray, x, y); 67 - } else { 68 - return quantizeSimple(gray); 69 - } 70 - } 71 - 72 - // Floyd-Steinberg quantization with error diffusion and serpentine scanning 73 - // Returns 2-bit value (0-3) and updates error buffers 74 - static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow, 75 - bool reverseDir) { 76 - // Add accumulated error to this pixel 77 - int adjusted = gray + errorCurRow[x + 1]; 78 - 79 - // Clamp to valid range 80 - if (adjusted < 0) adjusted = 0; 81 - if (adjusted > 255) adjusted = 255; 82 - 83 - // Quantize to 4 levels (0, 85, 170, 255) 84 - uint8_t quantized; 85 - int quantizedValue; 86 - if (adjusted < 43) { 87 - quantized = 0; 88 - quantizedValue = 0; 89 - } else if (adjusted < 128) { 90 - quantized = 1; 91 - quantizedValue = 85; 92 - } else if (adjusted < 213) { 93 - quantized = 2; 94 - quantizedValue = 170; 95 - } else { 96 - quantized = 3; 97 - quantizedValue = 255; 98 - } 99 - 100 - // Calculate error 101 - int error = adjusted - quantizedValue; 102 - 103 - // Distribute error to neighbors (serpentine: direction-aware) 104 - if (!reverseDir) { 105 - // Left to right 106 - errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16 107 - errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16 108 - errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 109 - errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16 110 - } else { 111 - // Right to left (mirrored) 112 - errorCurRow[x] += (error * 7) >> 4; // Left: 7/16 113 - errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16 114 - errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 115 - errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16 116 - } 117 - 118 - return quantized; 119 - } 120 - 121 14 Bitmap::~Bitmap() { 122 15 delete[] errorCurRow; 123 16 delete[] errorNextRow; 17 + 18 + delete atkinsonDitherer; 19 + delete fsDitherer; 124 20 } 125 21 126 22 uint16_t Bitmap::readLE16(FsFile& f) { ··· 244 140 return BmpReaderError::SeekPixelDataFailed; 245 141 } 246 142 247 - // Allocate Floyd-Steinberg error buffers if enabled 248 - if (USE_FLOYD_STEINBERG) { 249 - delete[] errorCurRow; 250 - delete[] errorNextRow; 251 - errorCurRow = new int16_t[width + 2](); // +2 for boundary handling 252 - errorNextRow = new int16_t[width + 2](); 253 - prevRowY = -1; 143 + // Create ditherer if enabled (only for 2-bit output) 144 + // Use OUTPUT dimensions for dithering (after prescaling) 145 + if (bpp > 2 && dithering) { 146 + if (USE_ATKINSON) { 147 + atkinsonDitherer = new AtkinsonDitherer(width); 148 + } else { 149 + fsDitherer = new FloydSteinbergDitherer(width); 150 + } 254 151 } 255 152 256 153 return BmpReaderError::Ok; ··· 261 158 // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' 262 159 if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; 263 160 264 - // Handle Floyd-Steinberg error buffer progression 265 - const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow; 266 - if (useFS) { 267 - if (prevRowY != -1) { 268 - // Sequential access - swap buffers 269 - int16_t* temp = errorCurRow; 270 - errorCurRow = errorNextRow; 271 - errorNextRow = temp; 272 - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); 273 - } 274 - } 275 161 prevRowY += 1; 276 162 277 163 uint8_t* outPtr = data; ··· 282 168 // Helper lambda to pack 2bpp color into the output stream 283 169 auto packPixel = [&](const uint8_t lum) { 284 170 uint8_t color; 285 - if (useFS) { 286 - // Floyd-Steinberg error diffusion 287 - color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); 171 + if (atkinsonDitherer) { 172 + color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX); 173 + } else if (fsDitherer) { 174 + color = fsDitherer->processPixel(adjustPixel(lum), currentX); 288 175 } else { 289 - // Simple quantization or noise dithering 290 - color = quantize(lum, currentX, prevRowY); 176 + if (bpp > 2) { 177 + // Simple quantization or noise dithering 178 + color = quantize(adjustPixel(lum), currentX, prevRowY); 179 + } else { 180 + // do not quantize 2bpp image 181 + color = static_cast<uint8_t>(lum >> 6); 182 + } 291 183 } 292 184 currentOutByte |= (color << bitShift); 293 185 if (bitShift == 0) { ··· 345 237 return BmpReaderError::UnsupportedBpp; 346 238 } 347 239 240 + if (atkinsonDitherer) 241 + atkinsonDitherer->nextRow(); 242 + else if (fsDitherer) 243 + fsDitherer->nextRow(); 244 + 348 245 // Flush remaining bits if width is not a multiple of 4 349 246 if (bitShift != 6) *outPtr = currentOutByte; 350 247 ··· 356 253 return BmpReaderError::SeekPixelDataFailed; 357 254 } 358 255 359 - // Reset Floyd-Steinberg error buffers when rewinding 360 - if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { 361 - memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); 362 - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); 363 - prevRowY = -1; 364 - } 256 + // Reset dithering when rewinding 257 + if (fsDitherer) fsDitherer->reset(); 258 + if (atkinsonDitherer) atkinsonDitherer->reset(); 365 259 366 260 return BmpReaderError::Ok; 367 261 }
+9 -1
lib/GfxRenderer/Bitmap.h
··· 2 2 3 3 #include <SdFat.h> 4 4 5 + #include <cstdint> 6 + 7 + #include "BitmapHelpers.h" 8 + 5 9 enum class BmpReaderError : uint8_t { 6 10 Ok = 0, 7 11 FileInvalid, ··· 28 32 public: 29 33 static const char* errorToString(BmpReaderError err); 30 34 31 - explicit Bitmap(FsFile& file) : file(file) {} 35 + explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {} 32 36 ~Bitmap(); 33 37 BmpReaderError parseHeaders(); 34 38 BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; ··· 44 48 static uint32_t readLE32(FsFile& f); 45 49 46 50 FsFile& file; 51 + bool dithering = false; 47 52 int width = 0; 48 53 int height = 0; 49 54 bool topDown = false; ··· 56 61 mutable int16_t* errorCurRow = nullptr; 57 62 mutable int16_t* errorNextRow = nullptr; 58 63 mutable int prevRowY = -1; // Track row progression for error propagation 64 + 65 + mutable AtkinsonDitherer* atkinsonDitherer = nullptr; 66 + mutable FloydSteinbergDitherer* fsDitherer = nullptr; 59 67 };
+90
lib/GfxRenderer/BitmapHelpers.cpp
··· 1 + #include "BitmapHelpers.h" 2 + 3 + #include <cstdint> 4 + 5 + // Brightness/Contrast adjustments: 6 + constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments 7 + constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) 8 + constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) 9 + constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) 10 + constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering 11 + 12 + // Integer approximation of gamma correction (brightens midtones) 13 + // Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) 14 + static inline int applyGamma(int gray) { 15 + if (!GAMMA_CORRECTION) return gray; 16 + // Fast integer square root approximation for gamma ~0.5 (brightening) 17 + // This brightens dark/mid tones while preserving highlights 18 + const int product = gray * 255; 19 + // Newton-Raphson integer sqrt (2 iterations for good accuracy) 20 + int x = gray; 21 + if (x > 0) { 22 + x = (x + product / x) >> 1; 23 + x = (x + product / x) >> 1; 24 + } 25 + return x > 255 ? 255 : x; 26 + } 27 + 28 + // Apply contrast adjustment around midpoint (128) 29 + // factor > 1.0 increases contrast, < 1.0 decreases 30 + static inline int applyContrast(int gray) { 31 + // Integer-based contrast: (gray - 128) * factor + 128 32 + // Using fixed-point: factor 1.15 ≈ 115/100 33 + constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100); 34 + int adjusted = ((gray - 128) * factorNum) / 100 + 128; 35 + if (adjusted < 0) adjusted = 0; 36 + if (adjusted > 255) adjusted = 255; 37 + return adjusted; 38 + } 39 + // Combined brightness/contrast/gamma adjustment 40 + int adjustPixel(int gray) { 41 + if (!USE_BRIGHTNESS) return gray; 42 + 43 + // Order: contrast first, then brightness, then gamma 44 + gray = applyContrast(gray); 45 + gray += BRIGHTNESS_BOOST; 46 + if (gray > 255) gray = 255; 47 + if (gray < 0) gray = 0; 48 + gray = applyGamma(gray); 49 + 50 + return gray; 51 + } 52 + // Simple quantization without dithering - divide into 4 levels 53 + // The thresholds are fine-tuned to the X4 display 54 + uint8_t quantizeSimple(int gray) { 55 + if (gray < 45) { 56 + return 0; 57 + } else if (gray < 70) { 58 + return 1; 59 + } else if (gray < 140) { 60 + return 2; 61 + } else { 62 + return 3; 63 + } 64 + } 65 + 66 + // Hash-based noise dithering - survives downsampling without moiré artifacts 67 + // Uses integer hash to generate pseudo-random threshold per pixel 68 + static inline uint8_t quantizeNoise(int gray, int x, int y) { 69 + uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u; 70 + hash = (hash ^ (hash >> 13)) * 1274126177u; 71 + const int threshold = static_cast<int>(hash >> 24); 72 + 73 + const int scaled = gray * 3; 74 + if (scaled < 255) { 75 + return (scaled + threshold >= 255) ? 1 : 0; 76 + } else if (scaled < 510) { 77 + return ((scaled - 255) + threshold >= 255) ? 2 : 1; 78 + } else { 79 + return ((scaled - 510) + threshold >= 255) ? 3 : 2; 80 + } 81 + } 82 + 83 + // Main quantization function - selects between methods based on config 84 + uint8_t quantize(int gray, int x, int y) { 85 + if (USE_NOISE_DITHERING) { 86 + return quantizeNoise(gray, x, y); 87 + } else { 88 + return quantizeSimple(gray); 89 + } 90 + }
+233
lib/GfxRenderer/BitmapHelpers.h
··· 1 + #pragma once 2 + 3 + #include <cstring> 4 + 5 + // Helper functions 6 + uint8_t quantize(int gray, int x, int y); 7 + uint8_t quantizeSimple(int gray); 8 + int adjustPixel(int gray); 9 + 10 + // Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results 11 + // Error distribution pattern: 12 + // X 1/8 1/8 13 + // 1/8 1/8 1/8 14 + // 1/8 15 + // Less error buildup = fewer artifacts than Floyd-Steinberg 16 + class AtkinsonDitherer { 17 + public: 18 + explicit AtkinsonDitherer(int width) : width(width) { 19 + errorRow0 = new int16_t[width + 4](); // Current row 20 + errorRow1 = new int16_t[width + 4](); // Next row 21 + errorRow2 = new int16_t[width + 4](); // Row after next 22 + } 23 + 24 + ~AtkinsonDitherer() { 25 + delete[] errorRow0; 26 + delete[] errorRow1; 27 + delete[] errorRow2; 28 + } 29 + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** 30 + AtkinsonDitherer(const AtkinsonDitherer& other) = delete; 31 + 32 + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** 33 + AtkinsonDitherer& operator=(const AtkinsonDitherer& other) = delete; 34 + 35 + uint8_t processPixel(int gray, int x) { 36 + // Add accumulated error 37 + int adjusted = gray + errorRow0[x + 2]; 38 + if (adjusted < 0) adjusted = 0; 39 + if (adjusted > 255) adjusted = 255; 40 + 41 + // Quantize to 4 levels 42 + uint8_t quantized; 43 + int quantizedValue; 44 + if (false) { // original thresholds 45 + if (adjusted < 43) { 46 + quantized = 0; 47 + quantizedValue = 0; 48 + } else if (adjusted < 128) { 49 + quantized = 1; 50 + quantizedValue = 85; 51 + } else if (adjusted < 213) { 52 + quantized = 2; 53 + quantizedValue = 170; 54 + } else { 55 + quantized = 3; 56 + quantizedValue = 255; 57 + } 58 + } else { // fine-tuned to X4 eink display 59 + if (adjusted < 30) { 60 + quantized = 0; 61 + quantizedValue = 15; 62 + } else if (adjusted < 50) { 63 + quantized = 1; 64 + quantizedValue = 30; 65 + } else if (adjusted < 140) { 66 + quantized = 2; 67 + quantizedValue = 80; 68 + } else { 69 + quantized = 3; 70 + quantizedValue = 210; 71 + } 72 + } 73 + 74 + // Calculate error (only distribute 6/8 = 75%) 75 + int error = (adjusted - quantizedValue) >> 3; // error/8 76 + 77 + // Distribute 1/8 to each of 6 neighbors 78 + errorRow0[x + 3] += error; // Right 79 + errorRow0[x + 4] += error; // Right+1 80 + errorRow1[x + 1] += error; // Bottom-left 81 + errorRow1[x + 2] += error; // Bottom 82 + errorRow1[x + 3] += error; // Bottom-right 83 + errorRow2[x + 2] += error; // Two rows down 84 + 85 + return quantized; 86 + } 87 + 88 + void nextRow() { 89 + int16_t* temp = errorRow0; 90 + errorRow0 = errorRow1; 91 + errorRow1 = errorRow2; 92 + errorRow2 = temp; 93 + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); 94 + } 95 + 96 + void reset() { 97 + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); 98 + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); 99 + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); 100 + } 101 + 102 + private: 103 + int width; 104 + int16_t* errorRow0; 105 + int16_t* errorRow1; 106 + int16_t* errorRow2; 107 + }; 108 + 109 + // Floyd-Steinberg error diffusion dithering with serpentine scanning 110 + // Serpentine scanning alternates direction each row to reduce "worm" artifacts 111 + // Error distribution pattern (left-to-right): 112 + // X 7/16 113 + // 3/16 5/16 1/16 114 + // Error distribution pattern (right-to-left, mirrored): 115 + // 1/16 5/16 3/16 116 + // 7/16 X 117 + class FloydSteinbergDitherer { 118 + public: 119 + explicit FloydSteinbergDitherer(int width) : width(width), rowCount(0) { 120 + errorCurRow = new int16_t[width + 2](); // +2 for boundary handling 121 + errorNextRow = new int16_t[width + 2](); 122 + } 123 + 124 + ~FloydSteinbergDitherer() { 125 + delete[] errorCurRow; 126 + delete[] errorNextRow; 127 + } 128 + 129 + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** 130 + FloydSteinbergDitherer(const FloydSteinbergDitherer& other) = delete; 131 + 132 + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** 133 + FloydSteinbergDitherer& operator=(const FloydSteinbergDitherer& other) = delete; 134 + 135 + // Process a single pixel and return quantized 2-bit value 136 + // x is the logical x position (0 to width-1), direction handled internally 137 + uint8_t processPixel(int gray, int x) { 138 + // Add accumulated error to this pixel 139 + int adjusted = gray + errorCurRow[x + 1]; 140 + 141 + // Clamp to valid range 142 + if (adjusted < 0) adjusted = 0; 143 + if (adjusted > 255) adjusted = 255; 144 + 145 + // Quantize to 4 levels (0, 85, 170, 255) 146 + uint8_t quantized; 147 + int quantizedValue; 148 + if (false) { // original thresholds 149 + if (adjusted < 43) { 150 + quantized = 0; 151 + quantizedValue = 0; 152 + } else if (adjusted < 128) { 153 + quantized = 1; 154 + quantizedValue = 85; 155 + } else if (adjusted < 213) { 156 + quantized = 2; 157 + quantizedValue = 170; 158 + } else { 159 + quantized = 3; 160 + quantizedValue = 255; 161 + } 162 + } else { // fine-tuned to X4 eink display 163 + if (adjusted < 30) { 164 + quantized = 0; 165 + quantizedValue = 15; 166 + } else if (adjusted < 50) { 167 + quantized = 1; 168 + quantizedValue = 30; 169 + } else if (adjusted < 140) { 170 + quantized = 2; 171 + quantizedValue = 80; 172 + } else { 173 + quantized = 3; 174 + quantizedValue = 210; 175 + } 176 + } 177 + 178 + // Calculate error 179 + int error = adjusted - quantizedValue; 180 + 181 + // Distribute error to neighbors (serpentine: direction-aware) 182 + if (!isReverseRow()) { 183 + // Left to right: standard distribution 184 + // Right: 7/16 185 + errorCurRow[x + 2] += (error * 7) >> 4; 186 + // Bottom-left: 3/16 187 + errorNextRow[x] += (error * 3) >> 4; 188 + // Bottom: 5/16 189 + errorNextRow[x + 1] += (error * 5) >> 4; 190 + // Bottom-right: 1/16 191 + errorNextRow[x + 2] += (error) >> 4; 192 + } else { 193 + // Right to left: mirrored distribution 194 + // Left: 7/16 195 + errorCurRow[x] += (error * 7) >> 4; 196 + // Bottom-right: 3/16 197 + errorNextRow[x + 2] += (error * 3) >> 4; 198 + // Bottom: 5/16 199 + errorNextRow[x + 1] += (error * 5) >> 4; 200 + // Bottom-left: 1/16 201 + errorNextRow[x] += (error) >> 4; 202 + } 203 + 204 + return quantized; 205 + } 206 + 207 + // Call at the end of each row to swap buffers 208 + void nextRow() { 209 + // Swap buffers 210 + int16_t* temp = errorCurRow; 211 + errorCurRow = errorNextRow; 212 + errorNextRow = temp; 213 + // Clear the next row buffer 214 + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); 215 + rowCount++; 216 + } 217 + 218 + // Check if current row should be processed in reverse 219 + bool isReverseRow() const { return (rowCount & 1) != 0; } 220 + 221 + // Reset for a new image or MCU block 222 + void reset() { 223 + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); 224 + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); 225 + rowCount = 0; 226 + } 227 + 228 + private: 229 + int width; 230 + int rowCount; 231 + int16_t* errorCurRow; 232 + int16_t* errorNextRow; 233 + };
+6 -274
lib/JpegToBmpConverter/JpegToBmpConverter.cpp
··· 7 7 #include <cstdio> 8 8 #include <cstring> 9 9 10 + #include "BitmapHelpers.h" 11 + 10 12 // Context structure for picojpeg callback 11 13 struct JpegReadContext { 12 14 FsFile& file; ··· 23 25 constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) 24 26 constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) 25 27 constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) 26 - // Brightness/Contrast adjustments: 27 - constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments 28 - constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) 29 - constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones) 30 - constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) 31 28 // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) 32 29 constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering 33 30 constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width) 34 31 constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) 35 32 // ============================================================================ 36 33 37 - // Integer approximation of gamma correction (brightens midtones) 38 - // Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) 39 - static inline int applyGamma(int gray) { 40 - if (!GAMMA_CORRECTION) return gray; 41 - // Fast integer square root approximation for gamma ~0.5 (brightening) 42 - // This brightens dark/mid tones while preserving highlights 43 - const int product = gray * 255; 44 - // Newton-Raphson integer sqrt (2 iterations for good accuracy) 45 - int x = gray; 46 - if (x > 0) { 47 - x = (x + product / x) >> 1; 48 - x = (x + product / x) >> 1; 49 - } 50 - return x > 255 ? 255 : x; 51 - } 52 - 53 - // Apply contrast adjustment around midpoint (128) 54 - // factor > 1.0 increases contrast, < 1.0 decreases 55 - static inline int applyContrast(int gray) { 56 - // Integer-based contrast: (gray - 128) * factor + 128 57 - // Using fixed-point: factor 1.15 ≈ 115/100 58 - constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100); 59 - int adjusted = ((gray - 128) * factorNum) / 100 + 128; 60 - if (adjusted < 0) adjusted = 0; 61 - if (adjusted > 255) adjusted = 255; 62 - return adjusted; 63 - } 64 - 65 - // Combined brightness/contrast/gamma adjustment 66 - static inline int adjustPixel(int gray) { 67 - if (!USE_BRIGHTNESS) return gray; 68 - 69 - // Order: contrast first, then brightness, then gamma 70 - gray = applyContrast(gray); 71 - gray += BRIGHTNESS_BOOST; 72 - if (gray > 255) gray = 255; 73 - if (gray < 0) gray = 0; 74 - gray = applyGamma(gray); 75 - 76 - return gray; 77 - } 78 - 79 - // Simple quantization without dithering - just divide into 4 levels 80 - static inline uint8_t quantizeSimple(int gray) { 81 - gray = adjustPixel(gray); 82 - // Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3 83 - return static_cast<uint8_t>(gray >> 6); 84 - } 85 - 86 - // Hash-based noise dithering - survives downsampling without moiré artifacts 87 - // Uses integer hash to generate pseudo-random threshold per pixel 88 - static inline uint8_t quantizeNoise(int gray, int x, int y) { 89 - gray = adjustPixel(gray); 90 - 91 - // Generate noise threshold using integer hash (no regular pattern to alias) 92 - uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u; 93 - hash = (hash ^ (hash >> 13)) * 1274126177u; 94 - const int threshold = static_cast<int>(hash >> 24); // 0-255 95 - 96 - // Map gray (0-255) to 4 levels with dithering 97 - const int scaled = gray * 3; 98 - 99 - if (scaled < 255) { 100 - return (scaled + threshold >= 255) ? 1 : 0; 101 - } else if (scaled < 510) { 102 - return ((scaled - 255) + threshold >= 255) ? 2 : 1; 103 - } else { 104 - return ((scaled - 510) + threshold >= 255) ? 3 : 2; 105 - } 106 - } 107 - 108 - // Main quantization function - selects between methods based on config 109 - static inline uint8_t quantize(int gray, int x, int y) { 110 - if (USE_NOISE_DITHERING) { 111 - return quantizeNoise(gray, x, y); 112 - } else { 113 - return quantizeSimple(gray); 114 - } 115 - } 116 - 117 - // Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results 118 - // Error distribution pattern: 119 - // X 1/8 1/8 120 - // 1/8 1/8 1/8 121 - // 1/8 122 - // Less error buildup = fewer artifacts than Floyd-Steinberg 123 - class AtkinsonDitherer { 124 - public: 125 - AtkinsonDitherer(int width) : width(width) { 126 - errorRow0 = new int16_t[width + 4](); // Current row 127 - errorRow1 = new int16_t[width + 4](); // Next row 128 - errorRow2 = new int16_t[width + 4](); // Row after next 129 - } 130 - 131 - ~AtkinsonDitherer() { 132 - delete[] errorRow0; 133 - delete[] errorRow1; 134 - delete[] errorRow2; 135 - } 136 - 137 - uint8_t processPixel(int gray, int x) { 138 - // Apply brightness/contrast/gamma adjustments 139 - gray = adjustPixel(gray); 140 - 141 - // Add accumulated error 142 - int adjusted = gray + errorRow0[x + 2]; 143 - if (adjusted < 0) adjusted = 0; 144 - if (adjusted > 255) adjusted = 255; 145 - 146 - // Quantize to 4 levels 147 - uint8_t quantized; 148 - int quantizedValue; 149 - if (adjusted < 43) { 150 - quantized = 0; 151 - quantizedValue = 0; 152 - } else if (adjusted < 128) { 153 - quantized = 1; 154 - quantizedValue = 85; 155 - } else if (adjusted < 213) { 156 - quantized = 2; 157 - quantizedValue = 170; 158 - } else { 159 - quantized = 3; 160 - quantizedValue = 255; 161 - } 162 - 163 - // Calculate error (only distribute 6/8 = 75%) 164 - int error = (adjusted - quantizedValue) >> 3; // error/8 165 - 166 - // Distribute 1/8 to each of 6 neighbors 167 - errorRow0[x + 3] += error; // Right 168 - errorRow0[x + 4] += error; // Right+1 169 - errorRow1[x + 1] += error; // Bottom-left 170 - errorRow1[x + 2] += error; // Bottom 171 - errorRow1[x + 3] += error; // Bottom-right 172 - errorRow2[x + 2] += error; // Two rows down 173 - 174 - return quantized; 175 - } 176 - 177 - void nextRow() { 178 - int16_t* temp = errorRow0; 179 - errorRow0 = errorRow1; 180 - errorRow1 = errorRow2; 181 - errorRow2 = temp; 182 - memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); 183 - } 184 - 185 - void reset() { 186 - memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); 187 - memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); 188 - memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); 189 - } 190 - 191 - private: 192 - int width; 193 - int16_t* errorRow0; 194 - int16_t* errorRow1; 195 - int16_t* errorRow2; 196 - }; 197 - 198 - // Floyd-Steinberg error diffusion dithering with serpentine scanning 199 - // Serpentine scanning alternates direction each row to reduce "worm" artifacts 200 - // Error distribution pattern (left-to-right): 201 - // X 7/16 202 - // 3/16 5/16 1/16 203 - // Error distribution pattern (right-to-left, mirrored): 204 - // 1/16 5/16 3/16 205 - // 7/16 X 206 - class FloydSteinbergDitherer { 207 - public: 208 - FloydSteinbergDitherer(int width) : width(width), rowCount(0) { 209 - errorCurRow = new int16_t[width + 2](); // +2 for boundary handling 210 - errorNextRow = new int16_t[width + 2](); 211 - } 212 - 213 - ~FloydSteinbergDitherer() { 214 - delete[] errorCurRow; 215 - delete[] errorNextRow; 216 - } 217 - 218 - // Process a single pixel and return quantized 2-bit value 219 - // x is the logical x position (0 to width-1), direction handled internally 220 - uint8_t processPixel(int gray, int x, bool reverseDirection) { 221 - // Add accumulated error to this pixel 222 - int adjusted = gray + errorCurRow[x + 1]; 223 - 224 - // Clamp to valid range 225 - if (adjusted < 0) adjusted = 0; 226 - if (adjusted > 255) adjusted = 255; 227 - 228 - // Quantize to 4 levels (0, 85, 170, 255) 229 - uint8_t quantized; 230 - int quantizedValue; 231 - if (adjusted < 43) { 232 - quantized = 0; 233 - quantizedValue = 0; 234 - } else if (adjusted < 128) { 235 - quantized = 1; 236 - quantizedValue = 85; 237 - } else if (adjusted < 213) { 238 - quantized = 2; 239 - quantizedValue = 170; 240 - } else { 241 - quantized = 3; 242 - quantizedValue = 255; 243 - } 244 - 245 - // Calculate error 246 - int error = adjusted - quantizedValue; 247 - 248 - // Distribute error to neighbors (serpentine: direction-aware) 249 - if (!reverseDirection) { 250 - // Left to right: standard distribution 251 - // Right: 7/16 252 - errorCurRow[x + 2] += (error * 7) >> 4; 253 - // Bottom-left: 3/16 254 - errorNextRow[x] += (error * 3) >> 4; 255 - // Bottom: 5/16 256 - errorNextRow[x + 1] += (error * 5) >> 4; 257 - // Bottom-right: 1/16 258 - errorNextRow[x + 2] += (error) >> 4; 259 - } else { 260 - // Right to left: mirrored distribution 261 - // Left: 7/16 262 - errorCurRow[x] += (error * 7) >> 4; 263 - // Bottom-right: 3/16 264 - errorNextRow[x + 2] += (error * 3) >> 4; 265 - // Bottom: 5/16 266 - errorNextRow[x + 1] += (error * 5) >> 4; 267 - // Bottom-left: 1/16 268 - errorNextRow[x] += (error) >> 4; 269 - } 270 - 271 - return quantized; 272 - } 273 - 274 - // Call at the end of each row to swap buffers 275 - void nextRow() { 276 - // Swap buffers 277 - int16_t* temp = errorCurRow; 278 - errorCurRow = errorNextRow; 279 - errorNextRow = temp; 280 - // Clear the next row buffer 281 - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); 282 - rowCount++; 283 - } 284 - 285 - // Check if current row should be processed in reverse 286 - bool isReverseRow() const { return (rowCount & 1) != 0; } 287 - 288 - // Reset for a new image or MCU block 289 - void reset() { 290 - memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); 291 - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); 292 - rowCount = 0; 293 - } 294 - 295 - private: 296 - int width; 297 - int rowCount; 298 - int16_t* errorCurRow; 299 - int16_t* errorNextRow; 300 - }; 301 - 302 34 inline void write16(Print& out, const uint16_t value) { 303 35 out.write(value & 0xFF); 304 36 out.write((value >> 8) & 0xFF); ··· 623 355 } 624 356 } else { 625 357 for (int x = 0; x < outWidth; x++) { 626 - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; 358 + const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]); 627 359 uint8_t twoBit; 628 360 if (atkinsonDitherer) { 629 361 twoBit = atkinsonDitherer->processPixel(gray, x); 630 362 } else if (fsDitherer) { 631 - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); 363 + twoBit = fsDitherer->processPixel(gray, x); 632 364 } else { 633 365 twoBit = quantize(gray, x, y); 634 366 } ··· 686 418 } 687 419 } else { 688 420 for (int x = 0; x < outWidth; x++) { 689 - const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; 421 + const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); 690 422 uint8_t twoBit; 691 423 if (atkinsonDitherer) { 692 424 twoBit = atkinsonDitherer->processPixel(gray, x); 693 425 } else if (fsDitherer) { 694 - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); 426 + twoBit = fsDitherer->processPixel(gray, x); 695 427 } else { 696 428 twoBit = quantize(gray, x, currentOutY); 697 429 }
+2 -2
src/activities/boot_sleep/SleepActivity.cpp
··· 86 86 if (SdMan.openFileForRead("SLP", filename, file)) { 87 87 Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); 88 88 delay(100); 89 - Bitmap bitmap(file); 89 + Bitmap bitmap(file, true); 90 90 if (bitmap.parseHeaders() == BmpReaderError::Ok) { 91 91 renderBitmapSleepScreen(bitmap); 92 92 dir.close(); ··· 101 101 // render a custom sleep screen instead of the default. 102 102 FsFile file; 103 103 if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { 104 - Bitmap bitmap(file); 104 + Bitmap bitmap(file, true); 105 105 if (bitmap.parseHeaders() == BmpReaderError::Ok) { 106 106 Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); 107 107 renderBitmapSleepScreen(bitmap);