From c4e3c244ea28072f5617b936743b62a833267120 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 19 Feb 2026 12:33:29 +0100 Subject: [PATCH] feat: Add 4bit bmp support (#944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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**_ --- lib/GfxRenderer/Bitmap.cpp | 59 ++++-- lib/GfxRenderer/Bitmap.h | 4 +- scripts/generate_test_bmps.py | 329 ++++++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 scripts/generate_test_bmps.py diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index ad25ffcd..776e52f3 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -4,10 +4,12 @@ #include // ============================================================================ -// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations +// IMAGE PROCESSING OPTIONS // ============================================================================ -// Note: For cover images, dithering is done in JpegToBmpConverter.cpp -// This file handles BMP reading - use simple quantization to avoid double-dithering +// Dithering is applied when converting high-color BMPs to the display's native +// 2-bit (4-level) grayscale. Images whose palette entries all map to native +// gray levels (0, 85, 170, 255 ±21) are mapped directly without dithering. +// For cover images, dithering is done in JpegToBmpConverter.cpp instead. constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg // ============================================================================ @@ -57,7 +59,7 @@ const char* Bitmap::errorToString(BmpReaderError err) { case BmpReaderError::BadPlanes: return "BadPlanes (!= 1)"; case BmpReaderError::UnsupportedBpp: - return "UnsupportedBpp (expected 1, 2, 8, 24, or 32)"; + return "UnsupportedBpp (expected 1, 2, 4, 8, 24, or 32)"; case BmpReaderError::UnsupportedCompression: return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; case BmpReaderError::BadDimensions: @@ -103,7 +105,7 @@ BmpReaderError Bitmap::parseHeaders() { const uint16_t planes = readLE16(file); bpp = readLE16(file); const uint32_t comp = readLE32(file); - const bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32; + const bool validBpp = bpp == 1 || bpp == 2 || bpp == 4 || bpp == 8 || bpp == 24 || bpp == 32; if (planes != 1) return BmpReaderError::BadPlanes; if (!validBpp) return BmpReaderError::UnsupportedBpp; @@ -111,7 +113,9 @@ BmpReaderError Bitmap::parseHeaders() { if (!(comp == 0 || (bpp == 32 && comp == 3))) return BmpReaderError::UnsupportedCompression; file.seekCur(12); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter - const uint32_t colorsUsed = readLE32(file); + colorsUsed = readLE32(file); + // BMP spec: colorsUsed==0 means default (2^bpp for paletted formats) + if (colorsUsed == 0 && bpp <= 8) colorsUsed = 1u << bpp; if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge; file.seekCur(4); // biClrImportant @@ -140,9 +144,29 @@ BmpReaderError Bitmap::parseHeaders() { return BmpReaderError::SeekPixelDataFailed; } - // Create ditherer if enabled (only for 2-bit output) - // Use OUTPUT dimensions for dithering (after prescaling) - if (bpp > 2 && dithering) { + // Check if palette luminances map cleanly to the display's 4 native gray levels. + // Native levels are 0, 85, 170, 255 — i.e. values where (lum >> 6) is lossless. + // If all palette entries are near a native level, we can skip dithering entirely. + nativePalette = bpp <= 2; // 1-bit and 2-bit are always native + if (!nativePalette && colorsUsed > 0) { + nativePalette = true; + for (uint32_t i = 0; i < colorsUsed; i++) { + const uint8_t lum = paletteLum[i]; + const uint8_t level = lum >> 6; // quantize to 0-3 + const uint8_t reconstructed = level * 85; // back to 0, 85, 170, 255 + if (lum > reconstructed + 21 || lum + 21 < reconstructed) { + nativePalette = false; // luminance is too far from any native level + break; + } + } + } + + // Decide pixel processing strategy: + // - Native palette → direct mapping, no processing needed + // - High-color + dithering enabled → error-diffusion dithering (Atkinson or Floyd-Steinberg) + // - High-color + dithering disabled → simple quantization (no error diffusion) + const bool highColor = !nativePalette; + if (highColor && dithering) { if (USE_ATKINSON) { atkinsonDitherer = new AtkinsonDitherer(width); } else { @@ -173,12 +197,12 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { } else if (fsDitherer) { color = fsDitherer->processPixel(adjustPixel(lum), currentX); } else { - if (bpp > 2) { - // Simple quantization or noise dithering - color = quantize(adjustPixel(lum), currentX, prevRowY); + if (nativePalette) { + // Palette matches native gray levels: direct mapping (still apply brightness/contrast/gamma) + color = static_cast(adjustPixel(lum) >> 6); } else { - // do not quantize 2bpp image - color = static_cast(lum >> 6); + // Non-native palette with dithering disabled: simple quantization + color = quantize(adjustPixel(lum), currentX, prevRowY); } } currentOutByte |= (color << bitShift); @@ -219,6 +243,13 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { } break; } + case 4: { + for (int x = 0; x < width; x++) { + const uint8_t nibble = (x & 1) ? (rowBuffer[x >> 1] & 0x0F) : (rowBuffer[x >> 1] >> 4); + packPixel(paletteLum[nibble]); + } + break; + } case 2: { for (int x = 0; x < width; x++) { lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 102283de..df6aef29 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -56,10 +56,12 @@ class Bitmap { bool topDown = false; uint32_t bfOffBits = 0; uint16_t bpp = 0; + uint32_t colorsUsed = 0; + bool nativePalette = false; // true if all palette entries map to native gray levels int rowBytes = 0; uint8_t paletteLum[256] = {}; - // Floyd-Steinberg dithering state (mutable for const methods) + // Dithering state (mutable for const methods) mutable int16_t* errorCurRow = nullptr; mutable int16_t* errorNextRow = nullptr; mutable int prevRowY = -1; // Track row progression for error propagation diff --git a/scripts/generate_test_bmps.py b/scripts/generate_test_bmps.py new file mode 100644 index 00000000..0e0c7894 --- /dev/null +++ b/scripts/generate_test_bmps.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +Generate test BMP images for verifying Bitmap.cpp format support. + +Creates BMP files at 480x800 (CrossPoint display in portrait orientation). +Test images use patterns designed to reveal dithering artifacts: + - Checkerboard: sharp edges between gray levels, dithering adds noise at boundaries + - Fine lines: thin 1px lines on contrasting background, dithering smears them + - Mixed blocks: small rectangles of alternating grays, dithering blurs transitions + - Gradient band: smooth transition in the middle, clean grays top/bottom + +Formats generated: +- 1-bit: black & white (baseline, never dithered) +- 2-bit: 4-level grayscale (non-standard CrossPoint extension, won't open on PC) +- 4-bit: 4-color grayscale palette (standard BMP, new support) +- 8-bit: 4-color grayscale palette (colorsUsed=4, should skip dithering) +- 8-bit: 256-color grayscale (full palette, should be dithered) +- 24-bit: RGB grayscale gradient (should be dithered) + +Usage: + python generate_test_bmps.py [output_dir] + Default output_dir: ./test_bmps/ +""" + +import struct +import os +import sys + +WIDTH = 480 +HEIGHT = 800 + +# The 4 e-ink gray levels (luminance values) +GRAY_LEVELS = [0, 85, 170, 255] + + +def write_bmp_file_header(f, pixel_data_offset, file_size): + f.write(b'BM') + f.write(struct.pack('> 3] |= (0x80 >> (x & 7)) + f.write(row) + + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") + + +def generate_2bit(path): + """2-bit BMP: 4-level grayscale test pattern (non-standard, CrossPoint extension).""" + bpp = 2 + palette = GRAY_LEVELS + row_bytes = (WIDTH * bpp + 31) // 32 * 4 + palette_size = len(palette) * 4 + pixel_offset = 14 + 40 + palette_size + file_size = pixel_offset + row_bytes * HEIGHT + + with open(path, 'wb') as f: + write_bmp_file_header(f, pixel_offset, file_size) + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) + write_palette(f, palette) + + for y in range(HEIGHT): + row = bytearray(row_bytes) + for x in range(WIDTH): + idx = get_test_pattern_index(x, y, WIDTH, HEIGHT) + byte_pos = x >> 2 + bit_shift = 6 - ((x & 3) * 2) + row[byte_pos] |= (idx << bit_shift) + f.write(row) + + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") + + +def generate_4bit(path): + """4-bit BMP: 4-color grayscale test pattern (standard, should skip dithering).""" + bpp = 4 + palette = GRAY_LEVELS + row_bytes = (WIDTH * bpp + 31) // 32 * 4 + palette_size = len(palette) * 4 + pixel_offset = 14 + 40 + palette_size + file_size = pixel_offset + row_bytes * HEIGHT + + with open(path, 'wb') as f: + write_bmp_file_header(f, pixel_offset, file_size) + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) + write_palette(f, palette) + + for y in range(HEIGHT): + row = bytearray(row_bytes) + for x in range(WIDTH): + idx = get_test_pattern_index(x, y, WIDTH, HEIGHT) + byte_pos = x >> 1 + if x & 1: + row[byte_pos] |= idx + else: + row[byte_pos] |= (idx << 4) + f.write(row) + + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") + + +def generate_8bit_4colors(path): + """8-bit BMP with only 4 palette entries (colorsUsed=4, should skip dithering).""" + bpp = 8 + palette = GRAY_LEVELS + row_bytes = (WIDTH * bpp + 31) // 32 * 4 + palette_size = len(palette) * 4 + pixel_offset = 14 + 40 + palette_size + file_size = pixel_offset + row_bytes * HEIGHT + + with open(path, 'wb') as f: + write_bmp_file_header(f, pixel_offset, file_size) + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) + write_palette(f, palette) + + for y in range(HEIGHT): + row = bytearray(row_bytes) + for x in range(WIDTH): + row[x] = get_test_pattern_index(x, y, WIDTH, HEIGHT) + f.write(row) + + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") + + +def generate_8bit_256colors(path): + """8-bit BMP with full 256 palette (should be dithered normally).""" + bpp = 8 + palette = list(range(256)) + row_bytes = (WIDTH * bpp + 31) // 32 * 4 + palette_size = len(palette) * 4 + pixel_offset = 14 + 40 + palette_size + file_size = pixel_offset + row_bytes * HEIGHT + + with open(path, 'wb') as f: + write_bmp_file_header(f, pixel_offset, file_size) + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, len(palette)) + write_palette(f, palette) + + for y in range(HEIGHT): + row = bytearray(row_bytes) + for x in range(WIDTH): + row[x] = get_test_pattern_lum(x, y, WIDTH, HEIGHT) + f.write(row) + + print(f" Created: {path} ({bpp}-bit, {len(palette)} colors)") + + +def generate_24bit(path): + """24-bit BMP: RGB grayscale test pattern (should be dithered normally).""" + bpp = 24 + row_bytes = (WIDTH * bpp + 31) // 32 * 4 + pixel_offset = 14 + 40 + file_size = pixel_offset + row_bytes * HEIGHT + + with open(path, 'wb') as f: + write_bmp_file_header(f, pixel_offset, file_size) + write_bmp_dib_header(f, WIDTH, HEIGHT, bpp, 0) + + for y in range(HEIGHT): + row = bytearray(row_bytes) + for x in range(WIDTH): + gray = get_test_pattern_lum(x, y, WIDTH, HEIGHT) + offset = x * 3 + row[offset] = gray # B + row[offset + 1] = gray # G + row[offset + 2] = gray # R + f.write(row) + + print(f" Created: {path} ({bpp}-bit)") + + +def main(): + output_dir = sys.argv[1] if len(sys.argv) > 1 else './test_bmps' + os.makedirs(output_dir, exist_ok=True) + + print(f"Generating test BMPs in {output_dir}/") + print(f"Resolution: {WIDTH}x{HEIGHT}") + print() + + generate_1bit(os.path.join(output_dir, 'test_1bit_bw.bmp')) + generate_2bit(os.path.join(output_dir, 'test_2bit_4gray.bmp')) + generate_4bit(os.path.join(output_dir, 'test_4bit_4gray.bmp')) + generate_8bit_4colors(os.path.join(output_dir, 'test_8bit_4colors.bmp')) + generate_8bit_256colors(os.path.join(output_dir, 'test_8bit_256gray_gradient.bmp')) + generate_24bit(os.path.join(output_dir, 'test_24bit_gradient.bmp')) + + print() + print("Test pattern layout (4 horizontal bands):") + print(" Band 1 (top): 16x16 checkerboard cycling all 4 gray levels") + print(" Band 2: Fine 1px horizontal lines alternating grays") + print(" Band 3: 4 stripes with nested 4x4 checkerboard detail") + print(" Band 4 (bottom): Diagonal bands cycling all 4 levels") + print() + print("What to look for:") + print(" Direct mapping: Sharp, clean edges between gray blocks") + print(" Dithering: Noisy/speckled boundaries, smeared fine lines") + print() + print("Expected results on device:") + print(" 1-bit: Clean B&W checkerboard, no dithering") + print(" 2-bit: Clean 4-gray pattern, no dithering (non-standard BMP, won't open on PC)") + print(" 4-bit: Clean 4-gray pattern, no dithering (standard BMP, viewable on PC)") + print(" 8-bit (4 colors): Clean 4-gray pattern, no dithering (standard BMP, viewable on PC)") + print(" 8-bit (256 colors): Same layout but with intermediate grays, WITH dithering") + print(" 24-bit: Same layout but with intermediate grays, WITH dithering") + print() + print("Note: 2-bit BMP is a non-standard CrossPoint extension. Standard image viewers") + print("will not open it. Use the 4-bit BMP instead for images created with standard tools") + print("(e.g. ImageMagick: convert input.png -colorspace Gray -colors 4 -depth 4 BMP3:output.bmp)") + print() + print("Copy files to /sleep/ folder on SD card to test as custom sleep screen images.") + + +if __name__ == '__main__': + main()