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()