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.

feat: Add 4bit bmp support (#944)

## Summary

* What is the goal of this PR?
- Allow users to create custom sleep screen images with standard tools
(ImageMagick, GIMP, etc.) that render cleanly on the e-ink display
without dithering artifacts. Previously, avoiding dithering required
non-standard 2-bit BMPs that no standard image editor can produce. ( see
issue #931 )

* What changes are included?
- Add 4-bit BMP format support to Bitmap.cpp (standard format, widely
supported by image tools)
- Auto-detect "native palette" images: if a BMP has ≤4 palette entries
and all luminances map within ±21 of the display's native gray levels
(0, 85, 170, 255), skip dithering entirely and direct-map pixels
- Clarify pixel processing strategy with three distinct paths:
error-diffusion dithering, simple quantization, or direct mapping
- Add scripts/generate_test_bmps.py for generating test images across
all supported BMP formats

## Additional Context

* The e-ink display has 4 native gray levels. When a BMP already uses
exactly those levels, dithering adds noise to what should be clean
output. The native palette detection uses a ±21 tolerance (~10%) to
handle slight rounding from color space conversions in image tools.
Users can now create a 4-color grayscale BMP with (imagemagic example):
```
convert input.png -colorspace Gray -colors 4 -depth
```
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _** YES**_

authored by

jpirnay and committed by
GitHub
c4e3c244 3c1bd778

+377 -15
+45 -14
lib/GfxRenderer/Bitmap.cpp
··· 4 4 #include <cstring> 5 5 6 6 // ============================================================================ 7 - // IMAGE PROCESSING OPTIONS - Toggle these to test different configurations 7 + // IMAGE PROCESSING OPTIONS 8 8 // ============================================================================ 9 - // Note: For cover images, dithering is done in JpegToBmpConverter.cpp 10 - // This file handles BMP reading - use simple quantization to avoid double-dithering 9 + // Dithering is applied when converting high-color BMPs to the display's native 10 + // 2-bit (4-level) grayscale. Images whose palette entries all map to native 11 + // gray levels (0, 85, 170, 255 ±21) are mapped directly without dithering. 12 + // For cover images, dithering is done in JpegToBmpConverter.cpp instead. 11 13 constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg 12 14 // ============================================================================ 13 15 ··· 57 59 case BmpReaderError::BadPlanes: 58 60 return "BadPlanes (!= 1)"; 59 61 case BmpReaderError::UnsupportedBpp: 60 - return "UnsupportedBpp (expected 1, 2, 8, 24, or 32)"; 62 + return "UnsupportedBpp (expected 1, 2, 4, 8, 24, or 32)"; 61 63 case BmpReaderError::UnsupportedCompression: 62 64 return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; 63 65 case BmpReaderError::BadDimensions: ··· 103 105 const uint16_t planes = readLE16(file); 104 106 bpp = readLE16(file); 105 107 const uint32_t comp = readLE32(file); 106 - const bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32; 108 + const bool validBpp = bpp == 1 || bpp == 2 || bpp == 4 || bpp == 8 || bpp == 24 || bpp == 32; 107 109 108 110 if (planes != 1) return BmpReaderError::BadPlanes; 109 111 if (!validBpp) return BmpReaderError::UnsupportedBpp; ··· 111 113 if (!(comp == 0 || (bpp == 32 && comp == 3))) return BmpReaderError::UnsupportedCompression; 112 114 113 115 file.seekCur(12); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter 114 - const uint32_t colorsUsed = readLE32(file); 116 + colorsUsed = readLE32(file); 117 + // BMP spec: colorsUsed==0 means default (2^bpp for paletted formats) 118 + if (colorsUsed == 0 && bpp <= 8) colorsUsed = 1u << bpp; 115 119 if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge; 116 120 file.seekCur(4); // biClrImportant 117 121 ··· 140 144 return BmpReaderError::SeekPixelDataFailed; 141 145 } 142 146 143 - // Create ditherer if enabled (only for 2-bit output) 144 - // Use OUTPUT dimensions for dithering (after prescaling) 145 - if (bpp > 2 && dithering) { 147 + // Check if palette luminances map cleanly to the display's 4 native gray levels. 148 + // Native levels are 0, 85, 170, 255 — i.e. values where (lum >> 6) is lossless. 149 + // If all palette entries are near a native level, we can skip dithering entirely. 150 + nativePalette = bpp <= 2; // 1-bit and 2-bit are always native 151 + if (!nativePalette && colorsUsed > 0) { 152 + nativePalette = true; 153 + for (uint32_t i = 0; i < colorsUsed; i++) { 154 + const uint8_t lum = paletteLum[i]; 155 + const uint8_t level = lum >> 6; // quantize to 0-3 156 + const uint8_t reconstructed = level * 85; // back to 0, 85, 170, 255 157 + if (lum > reconstructed + 21 || lum + 21 < reconstructed) { 158 + nativePalette = false; // luminance is too far from any native level 159 + break; 160 + } 161 + } 162 + } 163 + 164 + // Decide pixel processing strategy: 165 + // - Native palette → direct mapping, no processing needed 166 + // - High-color + dithering enabled → error-diffusion dithering (Atkinson or Floyd-Steinberg) 167 + // - High-color + dithering disabled → simple quantization (no error diffusion) 168 + const bool highColor = !nativePalette; 169 + if (highColor && dithering) { 146 170 if (USE_ATKINSON) { 147 171 atkinsonDitherer = new AtkinsonDitherer(width); 148 172 } else { ··· 173 197 } else if (fsDitherer) { 174 198 color = fsDitherer->processPixel(adjustPixel(lum), currentX); 175 199 } else { 176 - if (bpp > 2) { 177 - // Simple quantization or noise dithering 200 + if (nativePalette) { 201 + // Palette matches native gray levels: direct mapping (still apply brightness/contrast/gamma) 202 + color = static_cast<uint8_t>(adjustPixel(lum) >> 6); 203 + } else { 204 + // Non-native palette with dithering disabled: simple quantization 178 205 color = quantize(adjustPixel(lum), currentX, prevRowY); 179 - } else { 180 - // do not quantize 2bpp image 181 - color = static_cast<uint8_t>(lum >> 6); 182 206 } 183 207 } 184 208 currentOutByte |= (color << bitShift); ··· 216 240 case 8: { 217 241 for (int x = 0; x < width; x++) { 218 242 packPixel(paletteLum[rowBuffer[x]]); 243 + } 244 + break; 245 + } 246 + case 4: { 247 + for (int x = 0; x < width; x++) { 248 + const uint8_t nibble = (x & 1) ? (rowBuffer[x >> 1] & 0x0F) : (rowBuffer[x >> 1] >> 4); 249 + packPixel(paletteLum[nibble]); 219 250 } 220 251 break; 221 252 }
+3 -1
lib/GfxRenderer/Bitmap.h
··· 56 56 bool topDown = false; 57 57 uint32_t bfOffBits = 0; 58 58 uint16_t bpp = 0; 59 + uint32_t colorsUsed = 0; 60 + bool nativePalette = false; // true if all palette entries map to native gray levels 59 61 int rowBytes = 0; 60 62 uint8_t paletteLum[256] = {}; 61 63 62 - // Floyd-Steinberg dithering state (mutable for const methods) 64 + // Dithering state (mutable for const methods) 63 65 mutable int16_t* errorCurRow = nullptr; 64 66 mutable int16_t* errorNextRow = nullptr; 65 67 mutable int prevRowY = -1; // Track row progression for error propagation
+329
scripts/generate_test_bmps.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Generate test BMP images for verifying Bitmap.cpp format support. 4 + 5 + Creates BMP files at 480x800 (CrossPoint display in portrait orientation). 6 + Test images use patterns designed to reveal dithering artifacts: 7 + - Checkerboard: sharp edges between gray levels, dithering adds noise at boundaries 8 + - Fine lines: thin 1px lines on contrasting background, dithering smears them 9 + - Mixed blocks: small rectangles of alternating grays, dithering blurs transitions 10 + - Gradient band: smooth transition in the middle, clean grays top/bottom 11 + 12 + Formats generated: 13 + - 1-bit: black & white (baseline, never dithered) 14 + - 2-bit: 4-level grayscale (non-standard CrossPoint extension, won't open on PC) 15 + - 4-bit: 4-color grayscale palette (standard BMP, new support) 16 + - 8-bit: 4-color grayscale palette (colorsUsed=4, should skip dithering) 17 + - 8-bit: 256-color grayscale (full palette, should be dithered) 18 + - 24-bit: RGB grayscale gradient (should be dithered) 19 + 20 + Usage: 21 + python generate_test_bmps.py [output_dir] 22 + Default output_dir: ./test_bmps/ 23 + """ 24 + 25 + import struct 26 + import os 27 + import sys 28 + 29 + WIDTH = 480 30 + HEIGHT = 800 31 + 32 + # The 4 e-ink gray levels (luminance values) 33 + GRAY_LEVELS = [0, 85, 170, 255] 34 + 35 + 36 + def write_bmp_file_header(f, pixel_data_offset, file_size): 37 + f.write(b'BM') 38 + f.write(struct.pack('<I', file_size)) 39 + f.write(struct.pack('<HH', 0, 0)) # reserved 40 + f.write(struct.pack('<I', pixel_data_offset)) 41 + 42 + 43 + def write_bmp_dib_header(f, width, height, bpp, colors_used=0): 44 + f.write(struct.pack('<I', 40)) # DIB header size (BITMAPINFOHEADER) 45 + f.write(struct.pack('<i', width)) 46 + f.write(struct.pack('<i', -height)) # negative = top-down 47 + f.write(struct.pack('<HH', 1, bpp)) # planes, bpp 48 + f.write(struct.pack('<I', 0)) # compression (BI_RGB) 49 + f.write(struct.pack('<I', 0)) # image size (can be 0 for BI_RGB) 50 + f.write(struct.pack('<i', 0)) # X pixels per meter 51 + f.write(struct.pack('<i', 0)) # Y pixels per meter 52 + f.write(struct.pack('<I', colors_used)) 53 + f.write(struct.pack('<I', 0)) # important colors 54 + 55 + 56 + def write_palette(f, entries): 57 + """Write palette entries as BGRA (B, G, R, 0x00).""" 58 + for gray in entries: 59 + f.write(struct.pack('BBBB', gray, gray, gray, 0)) 60 + 61 + 62 + def get_test_pattern_index(x, y, width, height): 63 + """Return palette index (0-3) for a test pattern designed to reveal dithering. 64 + 65 + Layout (4 horizontal bands): 66 + Band 1 (top 25%): Checkerboard of all 4 gray levels in 16x16 blocks 67 + Band 2 (25-50%): Fine horizontal lines (1px) alternating gray levels 68 + Band 3 (50-75%): 4 vertical stripes, each with a nested smaller pattern 69 + Band 4 (bottom 25%): Diagonal gradient-like pattern using the 4 levels 70 + """ 71 + band = y * 4 // height 72 + 73 + if band == 0: 74 + # Checkerboard: 16x16 blocks cycling through all 4 gray levels 75 + bx = (x // 16) % 4 76 + by = (y // 16) % 4 77 + return (bx + by) % 4 78 + 79 + elif band == 1: 80 + # Fine lines: 1px horizontal lines alternating between two gray levels 81 + # Left half: alternates black/white, Right half: alternates dark/light gray 82 + if x < width // 2: 83 + return 0 if (y % 2 == 0) else 3 # black/white 84 + else: 85 + return 1 if (y % 2 == 0) else 2 # dark gray/light gray 86 + 87 + elif band == 2: 88 + # 4 vertical stripes, each containing a smaller checkerboard at 4x4 89 + stripe = min(x * 4 // width, 3) 90 + inner = ((x // 4) + (y // 4)) % 2 91 + if stripe == 0: 92 + return 0 if inner else 1 # black / dark gray 93 + elif stripe == 1: 94 + return 1 if inner else 2 # dark gray / light gray 95 + elif stripe == 2: 96 + return 2 if inner else 3 # light gray / white 97 + else: 98 + return 0 if inner else 3 # black / white (max contrast) 99 + 100 + else: 101 + # Diagonal bands using all 4 levels 102 + return ((x + y) // 20) % 4 103 + 104 + 105 + def get_test_pattern_lum(x, y, width, height): 106 + """Return 0-255 luminance for a continuous-tone test pattern. 107 + 108 + Same layout as index version but uses full grayscale range 109 + to show how dithering handles non-native gray values. 110 + """ 111 + band = y * 4 // height 112 + 113 + if band == 0: 114 + # Checkerboard with intermediate grays (not native levels) 115 + bx = (x // 16) % 4 116 + by = (y // 16) % 4 117 + # Use values between native levels to force dithering decisions 118 + values = [0, 64, 128, 192] 119 + return values[(bx + by) % 4] 120 + 121 + elif band == 1: 122 + # Fine lines with intermediate values 123 + if x < width // 2: 124 + return 32 if (y % 2 == 0) else 224 # near-black / near-white 125 + else: 126 + return 96 if (y % 2 == 0) else 160 # mid-grays 127 + 128 + elif band == 2: 129 + # Smooth horizontal gradient across full width 130 + return (x * 255) // (width - 1) 131 + 132 + else: 133 + # Diagonal bands with smooth transitions 134 + return ((x + y) * 255 // (width + height)) 135 + 136 + 137 + def generate_1bit(path): 138 + """1-bit BMP: checkerboard pattern.""" 139 + bpp = 1 140 + palette = [0, 255] 141 + row_bytes = (WIDTH * bpp + 31) // 32 * 4 142 + palette_size = len(palette) * 4 143 + pixel_offset = 14 + 40 + palette_size 144 + file_size = pixel_offset + row_bytes * HEIGHT 145 + 146 + with open(path, 'wb') as f: 147 + write_bmp_file_header(f, pixel_offset, file_size) 148 + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) 149 + write_palette(f, palette) 150 + 151 + for y in range(HEIGHT): 152 + row = bytearray(row_bytes) 153 + for x in range(WIDTH): 154 + # 16x16 checkerboard 155 + val = ((x // 16) + (y // 16)) % 2 156 + if val: 157 + row[x >> 3] |= (0x80 >> (x & 7)) 158 + f.write(row) 159 + 160 + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") 161 + 162 + 163 + def generate_2bit(path): 164 + """2-bit BMP: 4-level grayscale test pattern (non-standard, CrossPoint extension).""" 165 + bpp = 2 166 + palette = GRAY_LEVELS 167 + row_bytes = (WIDTH * bpp + 31) // 32 * 4 168 + palette_size = len(palette) * 4 169 + pixel_offset = 14 + 40 + palette_size 170 + file_size = pixel_offset + row_bytes * HEIGHT 171 + 172 + with open(path, 'wb') as f: 173 + write_bmp_file_header(f, pixel_offset, file_size) 174 + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) 175 + write_palette(f, palette) 176 + 177 + for y in range(HEIGHT): 178 + row = bytearray(row_bytes) 179 + for x in range(WIDTH): 180 + idx = get_test_pattern_index(x, y, WIDTH, HEIGHT) 181 + byte_pos = x >> 2 182 + bit_shift = 6 - ((x & 3) * 2) 183 + row[byte_pos] |= (idx << bit_shift) 184 + f.write(row) 185 + 186 + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") 187 + 188 + 189 + def generate_4bit(path): 190 + """4-bit BMP: 4-color grayscale test pattern (standard, should skip dithering).""" 191 + bpp = 4 192 + palette = GRAY_LEVELS 193 + row_bytes = (WIDTH * bpp + 31) // 32 * 4 194 + palette_size = len(palette) * 4 195 + pixel_offset = 14 + 40 + palette_size 196 + file_size = pixel_offset + row_bytes * HEIGHT 197 + 198 + with open(path, 'wb') as f: 199 + write_bmp_file_header(f, pixel_offset, file_size) 200 + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) 201 + write_palette(f, palette) 202 + 203 + for y in range(HEIGHT): 204 + row = bytearray(row_bytes) 205 + for x in range(WIDTH): 206 + idx = get_test_pattern_index(x, y, WIDTH, HEIGHT) 207 + byte_pos = x >> 1 208 + if x & 1: 209 + row[byte_pos] |= idx 210 + else: 211 + row[byte_pos] |= (idx << 4) 212 + f.write(row) 213 + 214 + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") 215 + 216 + 217 + def generate_8bit_4colors(path): 218 + """8-bit BMP with only 4 palette entries (colorsUsed=4, should skip dithering).""" 219 + bpp = 8 220 + palette = GRAY_LEVELS 221 + row_bytes = (WIDTH * bpp + 31) // 32 * 4 222 + palette_size = len(palette) * 4 223 + pixel_offset = 14 + 40 + palette_size 224 + file_size = pixel_offset + row_bytes * HEIGHT 225 + 226 + with open(path, 'wb') as f: 227 + write_bmp_file_header(f, pixel_offset, file_size) 228 + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) 229 + write_palette(f, palette) 230 + 231 + for y in range(HEIGHT): 232 + row = bytearray(row_bytes) 233 + for x in range(WIDTH): 234 + row[x] = get_test_pattern_index(x, y, WIDTH, HEIGHT) 235 + f.write(row) 236 + 237 + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") 238 + 239 + 240 + def generate_8bit_256colors(path): 241 + """8-bit BMP with full 256 palette (should be dithered normally).""" 242 + bpp = 8 243 + palette = list(range(256)) 244 + row_bytes = (WIDTH * bpp + 31) // 32 * 4 245 + palette_size = len(palette) * 4 246 + pixel_offset = 14 + 40 + palette_size 247 + file_size = pixel_offset + row_bytes * HEIGHT 248 + 249 + with open(path, 'wb') as f: 250 + write_bmp_file_header(f, pixel_offset, file_size) 251 + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) 252 + write_palette(f, palette) 253 + 254 + for y in range(HEIGHT): 255 + row = bytearray(row_bytes) 256 + for x in range(WIDTH): 257 + row[x] = get_test_pattern_lum(x, y, WIDTH, HEIGHT) 258 + f.write(row) 259 + 260 + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") 261 + 262 + 263 + def generate_24bit(path): 264 + """24-bit BMP: RGB grayscale test pattern (should be dithered normally).""" 265 + bpp = 24 266 + row_bytes = (WIDTH * bpp + 31) // 32 * 4 267 + pixel_offset = 14 + 40 268 + file_size = pixel_offset + row_bytes * HEIGHT 269 + 270 + with open(path, 'wb') as f: 271 + write_bmp_file_header(f, pixel_offset, file_size) 272 + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, 0) 273 + 274 + for y in range(HEIGHT): 275 + row = bytearray(row_bytes) 276 + for x in range(WIDTH): 277 + gray = get_test_pattern_lum(x, y, WIDTH, HEIGHT) 278 + offset = x * 3 279 + row[offset] = gray # B 280 + row[offset + 1] = gray # G 281 + row[offset + 2] = gray # R 282 + f.write(row) 283 + 284 + print(f" Created: {path} ({bpp}-bit)") 285 + 286 + 287 + def main(): 288 + output_dir = sys.argv[1] if len(sys.argv) > 1 else './test_bmps' 289 + os.makedirs(output_dir, exist_ok=True) 290 + 291 + print(f"Generating test BMPs in {output_dir}/") 292 + print(f"Resolution: {WIDTH}x{HEIGHT}") 293 + print() 294 + 295 + generate_1bit(os.path.join(output_dir, 'test_1bit_bw.bmp')) 296 + generate_2bit(os.path.join(output_dir, 'test_2bit_4gray.bmp')) 297 + generate_4bit(os.path.join(output_dir, 'test_4bit_4gray.bmp')) 298 + generate_8bit_4colors(os.path.join(output_dir, 'test_8bit_4colors.bmp')) 299 + generate_8bit_256colors(os.path.join(output_dir, 'test_8bit_256gray_gradient.bmp')) 300 + generate_24bit(os.path.join(output_dir, 'test_24bit_gradient.bmp')) 301 + 302 + print() 303 + print("Test pattern layout (4 horizontal bands):") 304 + print(" Band 1 (top): 16x16 checkerboard cycling all 4 gray levels") 305 + print(" Band 2: Fine 1px horizontal lines alternating grays") 306 + print(" Band 3: 4 stripes with nested 4x4 checkerboard detail") 307 + print(" Band 4 (bottom): Diagonal bands cycling all 4 levels") 308 + print() 309 + print("What to look for:") 310 + print(" Direct mapping: Sharp, clean edges between gray blocks") 311 + print(" Dithering: Noisy/speckled boundaries, smeared fine lines") 312 + print() 313 + print("Expected results on device:") 314 + print(" 1-bit: Clean B&W checkerboard, no dithering") 315 + print(" 2-bit: Clean 4-gray pattern, no dithering (non-standard BMP, won't open on PC)") 316 + print(" 4-bit: Clean 4-gray pattern, no dithering (standard BMP, viewable on PC)") 317 + print(" 8-bit (4 colors): Clean 4-gray pattern, no dithering (standard BMP, viewable on PC)") 318 + print(" 8-bit (256 colors): Same layout but with intermediate grays, WITH dithering") 319 + print(" 24-bit: Same layout but with intermediate grays, WITH dithering") 320 + print() 321 + print("Note: 2-bit BMP is a non-standard CrossPoint extension. Standard image viewers") 322 + print("will not open it. Use the 4-bit BMP instead for images created with standard tools") 323 + print("(e.g. ImageMagick: convert input.png -colorspace Gray -colors 4 -depth 4 BMP3:output.bmp)") 324 + print() 325 + print("Copy files to /sleep/ folder on SD card to test as custom sleep screen images.") 326 + 327 + 328 + if __name__ == '__main__': 329 + main()