/ lib / JpegToBmpConverter / JpegToBmpConverter.cpp
JpegToBmpConverter.cpp
  1  #include "JpegToBmpConverter.h"
  2  
  3  #include <HardwareSerial.h>
  4  #include <SdFat.h>
  5  #include <picojpeg.h>
  6  
  7  #include <cstdio>
  8  #include <cstring>
  9  
 10  #include "BitmapHelpers.h"
 11  
 12  // Context structure for picojpeg callback
 13  struct JpegReadContext {
 14    FsFile& file;
 15    uint8_t buffer[512];
 16    size_t bufferPos;
 17    size_t bufferFilled;
 18  };
 19  
 20  // ============================================================================
 21  // IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
 22  // ============================================================================
 23  constexpr bool USE_8BIT_OUTPUT = false;  // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels)
 24  // Dithering method selection (only one should be true, or all false for simple quantization):
 25  constexpr bool USE_ATKINSON = true;          // Atkinson dithering (cleaner than F-S, less error diffusion)
 26  constexpr bool USE_FLOYD_STEINBERG = false;  // Floyd-Steinberg error diffusion (can cause "worm" artifacts)
 27  constexpr bool USE_NOISE_DITHERING = false;  // Hash-based noise dithering (good for downsampling)
 28  // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling)
 29  constexpr bool USE_PRESCALE = true;     // true: scale image to target size before dithering
 30  constexpr int TARGET_MAX_WIDTH = 480;   // Max width for cover images (portrait display width)
 31  constexpr int TARGET_MAX_HEIGHT = 800;  // Max height for cover images (portrait display height)
 32  // ============================================================================
 33  
 34  inline void write16(Print& out, const uint16_t value) {
 35    out.write(value & 0xFF);
 36    out.write((value >> 8) & 0xFF);
 37  }
 38  
 39  inline void write32(Print& out, const uint32_t value) {
 40    out.write(value & 0xFF);
 41    out.write((value >> 8) & 0xFF);
 42    out.write((value >> 16) & 0xFF);
 43    out.write((value >> 24) & 0xFF);
 44  }
 45  
 46  inline void write32Signed(Print& out, const int32_t value) {
 47    out.write(value & 0xFF);
 48    out.write((value >> 8) & 0xFF);
 49    out.write((value >> 16) & 0xFF);
 50    out.write((value >> 24) & 0xFF);
 51  }
 52  
 53  // Helper function: Write BMP header with 8-bit grayscale (256 levels)
 54  void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
 55    // Calculate row padding (each row must be multiple of 4 bytes)
 56    const int bytesPerRow = (width + 3) / 4 * 4;  // 8 bits per pixel, padded
 57    const int imageSize = bytesPerRow * height;
 58    const uint32_t paletteSize = 256 * 4;  // 256 colors * 4 bytes (BGRA)
 59    const uint32_t fileSize = 14 + 40 + paletteSize + imageSize;
 60  
 61    // BMP File Header (14 bytes)
 62    bmpOut.write('B');
 63    bmpOut.write('M');
 64    write32(bmpOut, fileSize);
 65    write32(bmpOut, 0);                      // Reserved
 66    write32(bmpOut, 14 + 40 + paletteSize);  // Offset to pixel data
 67  
 68    // DIB Header (BITMAPINFOHEADER - 40 bytes)
 69    write32(bmpOut, 40);
 70    write32Signed(bmpOut, width);
 71    write32Signed(bmpOut, -height);  // Negative height = top-down bitmap
 72    write16(bmpOut, 1);              // Color planes
 73    write16(bmpOut, 8);              // Bits per pixel (8 bits)
 74    write32(bmpOut, 0);              // BI_RGB (no compression)
 75    write32(bmpOut, imageSize);
 76    write32(bmpOut, 2835);  // xPixelsPerMeter (72 DPI)
 77    write32(bmpOut, 2835);  // yPixelsPerMeter (72 DPI)
 78    write32(bmpOut, 256);   // colorsUsed
 79    write32(bmpOut, 256);   // colorsImportant
 80  
 81    // Color Palette (256 grayscale entries x 4 bytes = 1024 bytes)
 82    for (int i = 0; i < 256; i++) {
 83      bmpOut.write(static_cast<uint8_t>(i));  // Blue
 84      bmpOut.write(static_cast<uint8_t>(i));  // Green
 85      bmpOut.write(static_cast<uint8_t>(i));  // Red
 86      bmpOut.write(static_cast<uint8_t>(0));  // Reserved
 87    }
 88  }
 89  
 90  // Helper function: Write BMP header with 1-bit color depth (black and white)
 91  static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
 92    // Calculate row padding (each row must be multiple of 4 bytes)
 93    const int bytesPerRow = (width + 31) / 32 * 4;  // 1 bit per pixel, round up to 4-byte boundary
 94    const int imageSize = bytesPerRow * height;
 95    const uint32_t fileSize = 62 + imageSize;  // 14 (file header) + 40 (DIB header) + 8 (palette) + image
 96  
 97    // BMP File Header (14 bytes)
 98    bmpOut.write('B');
 99    bmpOut.write('M');
100    write32(bmpOut, fileSize);  // File size
101    write32(bmpOut, 0);         // Reserved
102    write32(bmpOut, 62);        // Offset to pixel data (14 + 40 + 8)
103  
104    // DIB Header (BITMAPINFOHEADER - 40 bytes)
105    write32(bmpOut, 40);
106    write32Signed(bmpOut, width);
107    write32Signed(bmpOut, -height);  // Negative height = top-down bitmap
108    write16(bmpOut, 1);              // Color planes
109    write16(bmpOut, 1);              // Bits per pixel (1 bit)
110    write32(bmpOut, 0);              // BI_RGB (no compression)
111    write32(bmpOut, imageSize);
112    write32(bmpOut, 2835);  // xPixelsPerMeter (72 DPI)
113    write32(bmpOut, 2835);  // yPixelsPerMeter (72 DPI)
114    write32(bmpOut, 2);     // colorsUsed
115    write32(bmpOut, 2);     // colorsImportant
116  
117    // Color Palette (2 colors x 4 bytes = 8 bytes)
118    // Format: Blue, Green, Red, Reserved (BGRA)
119    // Note: In 1-bit BMP, palette index 0 = black, 1 = white
120    uint8_t palette[8] = {
121        0x00, 0x00, 0x00, 0x00,  // Color 0: Black
122        0xFF, 0xFF, 0xFF, 0x00   // Color 1: White
123    };
124    for (const uint8_t i : palette) {
125      bmpOut.write(i);
126    }
127  }
128  
129  // Helper function: Write BMP header with 2-bit color depth
130  static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
131    // Calculate row padding (each row must be multiple of 4 bytes)
132    const int bytesPerRow = (width * 2 + 31) / 32 * 4;  // 2 bits per pixel, round up
133    const int imageSize = bytesPerRow * height;
134    const uint32_t fileSize = 70 + imageSize;  // 14 (file header) + 40 (DIB header) + 16 (palette) + image
135  
136    // BMP File Header (14 bytes)
137    bmpOut.write('B');
138    bmpOut.write('M');
139    write32(bmpOut, fileSize);  // File size
140    write32(bmpOut, 0);         // Reserved
141    write32(bmpOut, 70);        // Offset to pixel data
142  
143    // DIB Header (BITMAPINFOHEADER - 40 bytes)
144    write32(bmpOut, 40);
145    write32Signed(bmpOut, width);
146    write32Signed(bmpOut, -height);  // Negative height = top-down bitmap
147    write16(bmpOut, 1);              // Color planes
148    write16(bmpOut, 2);              // Bits per pixel (2 bits)
149    write32(bmpOut, 0);              // BI_RGB (no compression)
150    write32(bmpOut, imageSize);
151    write32(bmpOut, 2835);  // xPixelsPerMeter (72 DPI)
152    write32(bmpOut, 2835);  // yPixelsPerMeter (72 DPI)
153    write32(bmpOut, 4);     // colorsUsed
154    write32(bmpOut, 4);     // colorsImportant
155  
156    // Color Palette (4 colors x 4 bytes = 16 bytes)
157    // Format: Blue, Green, Red, Reserved (BGRA)
158    uint8_t palette[16] = {
159        0x00, 0x00, 0x00, 0x00,  // Color 0: Black
160        0x55, 0x55, 0x55, 0x00,  // Color 1: Dark gray (85)
161        0xAA, 0xAA, 0xAA, 0x00,  // Color 2: Light gray (170)
162        0xFF, 0xFF, 0xFF, 0x00   // Color 3: White
163    };
164    for (const uint8_t i : palette) {
165      bmpOut.write(i);
166    }
167  }
168  
169  // JPEG SOF markers - detect unsupported encoding types
170  // SOF0 (0xC0) = Baseline DCT (supported)
171  // SOF1 (0xC1) = Extended sequential DCT (supported)
172  // SOF2 (0xC2) = Progressive DCT (NOT supported)
173  // SOF9 (0xC9) = Extended sequential DCT, arithmetic (NOT supported)
174  // SOF10 (0xCA) = Progressive DCT, arithmetic (NOT supported)
175  static bool isUnsupportedJpeg(FsFile& file) {
176    const uint64_t originalPos = file.position();
177    file.seek(0);
178  
179    uint8_t buf[2];
180    bool isProgressive = false;
181  
182    // Scan for SOF marker
183    while (file.read(buf, 1) == 1) {
184      if (buf[0] != 0xFF) continue;
185  
186      if (file.read(buf, 1) != 1) break;
187  
188      // Skip padding FFs
189      while (buf[0] == 0xFF) {
190        if (file.read(buf, 1) != 1) break;
191      }
192  
193      const uint8_t marker = buf[0];
194  
195      // Check for unsupported SOF markers (progressive or arithmetic coding)
196      if (marker == 0xC2 || marker == 0xC9 || marker == 0xCA) {
197        isProgressive = true;
198        break;
199      }
200  
201      // Baseline/extended sequential DCT - supported
202      if (marker == 0xC0 || marker == 0xC1) {
203        isProgressive = false;
204        break;
205      }
206  
207      // Skip variable-length segments (not SOF, SOS, or standalone markers)
208      if (marker != 0xD8 && marker != 0xD9 && marker != 0x01 && !(marker >= 0xD0 && marker <= 0xD7)) {
209        if (file.read(buf, 2) != 2) break;
210        const uint16_t len = (buf[0] << 8) | buf[1];
211        if (len < 2) break;
212        file.seek(file.position() + len - 2);
213      }
214    }
215  
216    file.seek(originalPos);
217    return isProgressive;
218  }
219  
220  // Callback function for picojpeg to read JPEG data
221  unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size,
222                                                     unsigned char* pBytes_actually_read, void* pCallback_data) {
223    auto* context = static_cast<JpegReadContext*>(pCallback_data);
224  
225    if (!context || !context->file) {
226      return PJPG_STREAM_READ_ERROR;
227    }
228  
229    // Check if we need to refill our context buffer
230    if (context->bufferPos >= context->bufferFilled) {
231      context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer));
232      context->bufferPos = 0;
233  
234      if (context->bufferFilled == 0) {
235        // EOF or error
236        *pBytes_actually_read = 0;
237        return 0;  // Success (EOF is normal)
238      }
239    }
240  
241    // Copy available bytes to picojpeg's buffer
242    const size_t available = context->bufferFilled - context->bufferPos;
243    const size_t toRead = available < buf_size ? available : buf_size;
244  
245    memcpy(pBuf, context->buffer + context->bufferPos, toRead);
246    context->bufferPos += toRead;
247    *pBytes_actually_read = static_cast<unsigned char>(toRead);
248  
249    return 0;  // Success
250  }
251  
252  // Internal implementation with configurable target size and bit depth
253  bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
254                                                       bool oneBit, bool quickMode) {
255    Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)%s\n", millis(), oneBit ? "1-bit" : "2-bit",
256                  targetWidth, targetHeight, quickMode ? " [QUICK]" : "");
257  
258    // Check for unsupported JPEG encoding (progressive or arithmetic) before attempting decode
259    if (isUnsupportedJpeg(jpegFile)) {
260      Serial.printf("[%lu] [JPG] Unsupported JPEG encoding (progressive or arithmetic), skipping\n", millis());
261      return false;
262    }
263  
264    // Setup context for picojpeg callback
265    JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
266  
267    // Initialize picojpeg decoder
268    pjpeg_image_info_t imageInfo;
269    const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
270    if (status != 0) {
271      Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status);
272      return false;
273    }
274  
275    Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width,
276                  imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
277  
278    // Safety limits to prevent memory issues on ESP32
279    constexpr int MAX_IMAGE_WIDTH = 2048;
280    constexpr int MAX_IMAGE_HEIGHT = 3072;
281    constexpr int MAX_MCU_ROW_BYTES = 65536;
282  
283    if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
284      Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width,
285                    imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
286      return false;
287    }
288  
289    // Calculate output dimensions (pre-scale to fit display exactly)
290    int outWidth = imageInfo.m_width;
291    int outHeight = imageInfo.m_height;
292    // Use fixed-point scaling (16.16) for sub-pixel accuracy
293    uint32_t scaleX_fp = 65536;  // 1.0 in 16.16 fixed point
294    uint32_t scaleY_fp = 65536;
295    bool needsScaling = false;
296  
297    if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) {
298      // Calculate scale to fit within target dimensions while maintaining aspect ratio
299      const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
300      const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
301      // We scale to the smaller dimension, so we can potentially crop later.
302      const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
303  
304      outWidth = static_cast<int>(imageInfo.m_width * scale);
305      outHeight = static_cast<int>(imageInfo.m_height * scale);
306  
307      // Ensure at least 1 pixel
308      if (outWidth < 1) outWidth = 1;
309      if (outHeight < 1) outHeight = 1;
310  
311      // Calculate fixed-point scale factors (source pixels per output pixel)
312      // scaleX_fp = (srcWidth << 16) / outWidth
313      scaleX_fp = (static_cast<uint32_t>(imageInfo.m_width) << 16) / outWidth;
314      scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
315      needsScaling = true;
316  
317      Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width,
318                    imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight);
319    }
320  
321    // Write BMP header with output dimensions
322    int bytesPerRow;
323    if (USE_8BIT_OUTPUT && !oneBit) {
324      writeBmpHeader8bit(bmpOut, outWidth, outHeight);
325      bytesPerRow = (outWidth + 3) / 4 * 4;
326    } else if (oneBit) {
327      writeBmpHeader1bit(bmpOut, outWidth, outHeight);
328      bytesPerRow = (outWidth + 31) / 32 * 4;  // 1 bit per pixel
329    } else {
330      writeBmpHeader2bit(bmpOut, outWidth, outHeight);
331      bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
332    }
333  
334    // Allocate row buffer
335    auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
336    if (!rowBuffer) {
337      Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis());
338      return false;
339    }
340  
341    // Allocate a buffer for one MCU row worth of grayscale pixels
342    // This is the minimal memory needed for streaming conversion
343    const int mcuPixelHeight = imageInfo.m_MCUHeight;
344    const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight;
345  
346    // Validate MCU row buffer size before allocation
347    if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
348      Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels,
349                    MAX_MCU_ROW_BYTES);
350      free(rowBuffer);
351      return false;
352    }
353  
354    auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
355    if (!mcuRowBuffer) {
356      Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels);
357      free(rowBuffer);
358      return false;
359    }
360  
361    // Create ditherer if enabled (skip in quickMode for faster preview)
362    // Use OUTPUT dimensions for dithering (after prescaling)
363    AtkinsonDitherer* atkinsonDitherer = nullptr;
364    FloydSteinbergDitherer* fsDitherer = nullptr;
365    Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
366  
367    if (!quickMode) {
368      if (oneBit) {
369        // For 1-bit output, use Atkinson dithering for better quality
370        atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(outWidth);
371        if (!atkinson1BitDitherer) {
372          Serial.printf("[%lu] [JPG] Failed to allocate 1-bit ditherer\n", millis());
373          free(mcuRowBuffer);
374          free(rowBuffer);
375          return false;
376        }
377      } else if (!USE_8BIT_OUTPUT) {
378        if (USE_ATKINSON) {
379          atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(outWidth);
380          if (!atkinsonDitherer) {
381            Serial.printf("[%lu] [JPG] Failed to allocate Atkinson ditherer\n", millis());
382            free(mcuRowBuffer);
383            free(rowBuffer);
384            return false;
385          }
386        } else if (USE_FLOYD_STEINBERG) {
387          fsDitherer = new (std::nothrow) FloydSteinbergDitherer(outWidth);
388          if (!fsDitherer) {
389            Serial.printf("[%lu] [JPG] Failed to allocate Floyd-Steinberg ditherer\n", millis());
390            free(mcuRowBuffer);
391            free(rowBuffer);
392            return false;
393          }
394        }
395      }
396    }
397  
398    // For scaling: accumulate source rows into scaled output rows
399    // We need to track which source Y maps to which output Y
400    // Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format)
401    uint32_t* rowAccum = nullptr;    // Accumulator for each output X (32-bit for larger sums)
402    uint16_t* rowCount = nullptr;    // Count of source pixels accumulated per output X
403    int currentOutY = 0;             // Current output row being accumulated
404    uint32_t nextOutY_srcStart = 0;  // Source Y where next output row starts (16.16 fixed point)
405  
406    if (needsScaling) {
407      rowAccum = new uint32_t[outWidth]();
408      rowCount = new uint16_t[outWidth]();
409      nextOutY_srcStart = scaleY_fp;  // First boundary is at scaleY_fp (source Y for outY=1)
410    }
411  
412    // Process MCUs row-by-row and write to BMP as we go (top-down)
413    const int mcuPixelWidth = imageInfo.m_MCUWidth;
414  
415    for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) {
416      // Clear the MCU row buffer
417      memset(mcuRowBuffer, 0, mcuRowPixels);
418  
419      // Decode one row of MCUs
420      for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) {
421        const unsigned char mcuStatus = pjpeg_decode_mcu();
422        if (mcuStatus != 0) {
423          if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
424            Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY);
425          } else {
426            Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY,
427                          mcuStatus);
428          }
429          free(mcuRowBuffer);
430          free(rowBuffer);
431          return false;
432        }
433  
434        // picojpeg stores MCU data in 8x8 blocks
435        // Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128
436        for (int blockY = 0; blockY < mcuPixelHeight; blockY++) {
437          for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
438            const int pixelX = mcuX * mcuPixelWidth + blockX;
439            if (pixelX >= imageInfo.m_width) continue;
440  
441            // Calculate proper block offset for picojpeg buffer
442            const int blockCol = blockX / 8;
443            const int blockRow = blockY / 8;
444            const int localX = blockX % 8;
445            const int localY = blockY % 8;
446            const int blocksPerRow = mcuPixelWidth / 8;
447            const int blockIndex = blockRow * blocksPerRow + blockCol;
448            const int pixelOffset = blockIndex * 64 + localY * 8 + localX;
449  
450            uint8_t gray;
451            if (imageInfo.m_comps == 1) {
452              gray = imageInfo.m_pMCUBufR[pixelOffset];
453            } else {
454              const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset];
455              const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset];
456              const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset];
457              gray = rgbToGray(r, g, b);
458            }
459  
460            mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray;
461          }
462        }
463      }
464  
465      // Process source rows from this MCU row
466      const int startRow = mcuY * mcuPixelHeight;
467      const int endRow = (mcuY + 1) * mcuPixelHeight;
468  
469      for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) {
470        const int bufferY = y - startRow;
471  
472        if (!needsScaling) {
473          // No scaling - direct output (1:1 mapping)
474          memset(rowBuffer, 0, bytesPerRow);
475  
476          if (USE_8BIT_OUTPUT && !oneBit) {
477            for (int x = 0; x < outWidth; x++) {
478              const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
479              rowBuffer[x] = adjustPixel(gray);
480            }
481          } else if (oneBit) {
482            // 1-bit output with Atkinson dithering for better quality
483            for (int x = 0; x < outWidth; x++) {
484              const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
485              const uint8_t bit =
486                  atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y);
487              // Pack 1-bit value: MSB first, 8 pixels per byte
488              const int byteIndex = x / 8;
489              const int bitOffset = 7 - (x % 8);
490              rowBuffer[byteIndex] |= (bit << bitOffset);
491            }
492            if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
493          } else {
494            // 2-bit output
495            for (int x = 0; x < outWidth; x++) {
496              const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]);
497              uint8_t twoBit;
498              if (quickMode) {
499                // Quick mode: simple threshold (faster, no dithering)
500                twoBit = quantizeSimple(gray);
501              } else if (atkinsonDitherer) {
502                twoBit = atkinsonDitherer->processPixel(gray, x);
503              } else if (fsDitherer) {
504                twoBit = fsDitherer->processPixel(gray, x);
505              } else {
506                twoBit = quantize(gray, x, y);
507              }
508              const int byteIndex = (x * 2) / 8;
509              const int bitOffset = 6 - ((x * 2) % 8);
510              rowBuffer[byteIndex] |= (twoBit << bitOffset);
511            }
512            if (atkinsonDitherer)
513              atkinsonDitherer->nextRow();
514            else if (fsDitherer)
515              fsDitherer->nextRow();
516          }
517          bmpOut.write(rowBuffer, bytesPerRow);
518        } else {
519          // Fixed-point area averaging for exact fit scaling
520          // For each output pixel X, accumulate source pixels that map to it
521          // srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16)
522          const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width;
523  
524          for (int outX = 0; outX < outWidth; outX++) {
525            // Calculate source X range for this output pixel
526            const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16;
527            const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16;
528  
529            // Accumulate all source pixels in this range
530            int sum = 0;
531            int count = 0;
532            for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) {
533              sum += srcRow[srcX];
534              count++;
535            }
536  
537            // Handle edge case: if no pixels in range, use nearest
538            if (count == 0 && srcXStart < imageInfo.m_width) {
539              sum = srcRow[srcXStart];
540              count = 1;
541            }
542  
543            rowAccum[outX] += sum;
544            rowCount[outX] += count;
545          }
546  
547          // Check if we've crossed into the next output row
548          // Current source Y in fixed point: y << 16
549          const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
550  
551          // Output row when source Y crosses the boundary
552          if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
553            memset(rowBuffer, 0, bytesPerRow);
554  
555            if (USE_8BIT_OUTPUT && !oneBit) {
556              for (int x = 0; x < outWidth; x++) {
557                const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
558                rowBuffer[x] = adjustPixel(gray);
559              }
560            } else if (oneBit) {
561              // 1-bit output with Atkinson dithering for better quality
562              for (int x = 0; x < outWidth; x++) {
563                const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
564                const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x)
565                                                         : quantize1bit(gray, x, currentOutY);
566                // Pack 1-bit value: MSB first, 8 pixels per byte
567                const int byteIndex = x / 8;
568                const int bitOffset = 7 - (x % 8);
569                rowBuffer[byteIndex] |= (bit << bitOffset);
570              }
571              if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
572            } else {
573              // 2-bit output
574              for (int x = 0; x < outWidth; x++) {
575                const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
576                uint8_t twoBit;
577                if (quickMode) {
578                  // Quick mode: simple threshold (faster, no dithering)
579                  twoBit = quantizeSimple(gray);
580                } else if (atkinsonDitherer) {
581                  twoBit = atkinsonDitherer->processPixel(gray, x);
582                } else if (fsDitherer) {
583                  twoBit = fsDitherer->processPixel(gray, x);
584                } else {
585                  twoBit = quantize(gray, x, currentOutY);
586                }
587                const int byteIndex = (x * 2) / 8;
588                const int bitOffset = 6 - ((x * 2) % 8);
589                rowBuffer[byteIndex] |= (twoBit << bitOffset);
590              }
591              if (atkinsonDitherer)
592                atkinsonDitherer->nextRow();
593              else if (fsDitherer)
594                fsDitherer->nextRow();
595            }
596  
597            bmpOut.write(rowBuffer, bytesPerRow);
598            currentOutY++;
599  
600            // Reset accumulators for next output row
601            memset(rowAccum, 0, outWidth * sizeof(uint32_t));
602            memset(rowCount, 0, outWidth * sizeof(uint16_t));
603  
604            // Update boundary for next output row
605            nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
606          }
607        }
608      }
609    }
610  
611    // Clean up
612    if (rowAccum) {
613      delete[] rowAccum;
614    }
615    if (rowCount) {
616      delete[] rowCount;
617    }
618    if (atkinsonDitherer) {
619      delete atkinsonDitherer;
620    }
621    if (fsDitherer) {
622      delete fsDitherer;
623    }
624    if (atkinson1BitDitherer) {
625      delete atkinson1BitDitherer;
626    }
627    free(mcuRowBuffer);
628    free(rowBuffer);
629  
630    Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis());
631    return true;
632  }
633  
634  // Core function: Convert JPEG file to 2-bit BMP (uses default target size)
635  bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
636    return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false);
637  }
638  
639  // Convert with custom target size (for thumbnails, 2-bit)
640  bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
641                                                       int targetMaxHeight) {
642    return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
643  }
644  
645  // Convert to 1-bit BMP (black and white only, no grays) using default target size
646  bool JpegToBmpConverter::jpegFileTo1BitBmpStream(FsFile& jpegFile, Print& bmpOut) {
647    return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, true);
648  }
649  
650  // Convert to 1-bit BMP with custom target size (for thumbnails)
651  bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
652                                                           int targetMaxHeight) {
653    return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
654  }
655  
656  // Quick preview mode: simple threshold instead of dithering (faster but lower quality)
657  bool JpegToBmpConverter::jpegFileToBmpStreamQuick(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
658                                                    int targetMaxHeight) {
659    return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false, true);
660  }