mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 15:17:37 +03:00
## Summary Use non-crop mode as expected for home thumb generation. I likely broke this when I fixed the artifacts on the sleep screen. --- ### 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? NO
572 lines
23 KiB
C++
572 lines
23 KiB
C++
#include "JpegToBmpConverter.h"
|
|
|
|
#include <HardwareSerial.h>
|
|
#include <SdFat.h>
|
|
#include <picojpeg.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
#include "BitmapHelpers.h"
|
|
|
|
// Context structure for picojpeg callback
|
|
struct JpegReadContext {
|
|
FsFile& file;
|
|
uint8_t buffer[512];
|
|
size_t bufferPos;
|
|
size_t bufferFilled;
|
|
};
|
|
|
|
// ============================================================================
|
|
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
|
|
// ============================================================================
|
|
constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels)
|
|
// Dithering method selection (only one should be true, or all false for simple quantization):
|
|
constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion)
|
|
constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts)
|
|
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling)
|
|
// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling)
|
|
constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering
|
|
constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width)
|
|
constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height)
|
|
// ============================================================================
|
|
|
|
inline void write16(Print& out, const uint16_t value) {
|
|
out.write(value & 0xFF);
|
|
out.write((value >> 8) & 0xFF);
|
|
}
|
|
|
|
inline void write32(Print& out, const uint32_t value) {
|
|
out.write(value & 0xFF);
|
|
out.write((value >> 8) & 0xFF);
|
|
out.write((value >> 16) & 0xFF);
|
|
out.write((value >> 24) & 0xFF);
|
|
}
|
|
|
|
inline void write32Signed(Print& out, const int32_t value) {
|
|
out.write(value & 0xFF);
|
|
out.write((value >> 8) & 0xFF);
|
|
out.write((value >> 16) & 0xFF);
|
|
out.write((value >> 24) & 0xFF);
|
|
}
|
|
|
|
// Helper function: Write BMP header with 8-bit grayscale (256 levels)
|
|
void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
|
|
// Calculate row padding (each row must be multiple of 4 bytes)
|
|
const int bytesPerRow = (width + 3) / 4 * 4; // 8 bits per pixel, padded
|
|
const int imageSize = bytesPerRow * height;
|
|
const uint32_t paletteSize = 256 * 4; // 256 colors * 4 bytes (BGRA)
|
|
const uint32_t fileSize = 14 + 40 + paletteSize + imageSize;
|
|
|
|
// BMP File Header (14 bytes)
|
|
bmpOut.write('B');
|
|
bmpOut.write('M');
|
|
write32(bmpOut, fileSize);
|
|
write32(bmpOut, 0); // Reserved
|
|
write32(bmpOut, 14 + 40 + paletteSize); // Offset to pixel data
|
|
|
|
// DIB Header (BITMAPINFOHEADER - 40 bytes)
|
|
write32(bmpOut, 40);
|
|
write32Signed(bmpOut, width);
|
|
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
|
|
write16(bmpOut, 1); // Color planes
|
|
write16(bmpOut, 8); // Bits per pixel (8 bits)
|
|
write32(bmpOut, 0); // BI_RGB (no compression)
|
|
write32(bmpOut, imageSize);
|
|
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
|
|
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
|
|
write32(bmpOut, 256); // colorsUsed
|
|
write32(bmpOut, 256); // colorsImportant
|
|
|
|
// Color Palette (256 grayscale entries x 4 bytes = 1024 bytes)
|
|
for (int i = 0; i < 256; i++) {
|
|
bmpOut.write(static_cast<uint8_t>(i)); // Blue
|
|
bmpOut.write(static_cast<uint8_t>(i)); // Green
|
|
bmpOut.write(static_cast<uint8_t>(i)); // Red
|
|
bmpOut.write(static_cast<uint8_t>(0)); // Reserved
|
|
}
|
|
}
|
|
|
|
// Helper function: Write BMP header with 1-bit color depth (black and white)
|
|
static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
|
|
// Calculate row padding (each row must be multiple of 4 bytes)
|
|
const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary
|
|
const int imageSize = bytesPerRow * height;
|
|
const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image
|
|
|
|
// BMP File Header (14 bytes)
|
|
bmpOut.write('B');
|
|
bmpOut.write('M');
|
|
write32(bmpOut, fileSize); // File size
|
|
write32(bmpOut, 0); // Reserved
|
|
write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8)
|
|
|
|
// DIB Header (BITMAPINFOHEADER - 40 bytes)
|
|
write32(bmpOut, 40);
|
|
write32Signed(bmpOut, width);
|
|
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
|
|
write16(bmpOut, 1); // Color planes
|
|
write16(bmpOut, 1); // Bits per pixel (1 bit)
|
|
write32(bmpOut, 0); // BI_RGB (no compression)
|
|
write32(bmpOut, imageSize);
|
|
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
|
|
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
|
|
write32(bmpOut, 2); // colorsUsed
|
|
write32(bmpOut, 2); // colorsImportant
|
|
|
|
// Color Palette (2 colors x 4 bytes = 8 bytes)
|
|
// Format: Blue, Green, Red, Reserved (BGRA)
|
|
// Note: In 1-bit BMP, palette index 0 = black, 1 = white
|
|
uint8_t palette[8] = {
|
|
0x00, 0x00, 0x00, 0x00, // Color 0: Black
|
|
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
|
|
};
|
|
for (const uint8_t i : palette) {
|
|
bmpOut.write(i);
|
|
}
|
|
}
|
|
|
|
// Helper function: Write BMP header with 2-bit color depth
|
|
static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
|
|
// Calculate row padding (each row must be multiple of 4 bytes)
|
|
const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up
|
|
const int imageSize = bytesPerRow * height;
|
|
const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image
|
|
|
|
// BMP File Header (14 bytes)
|
|
bmpOut.write('B');
|
|
bmpOut.write('M');
|
|
write32(bmpOut, fileSize); // File size
|
|
write32(bmpOut, 0); // Reserved
|
|
write32(bmpOut, 70); // Offset to pixel data
|
|
|
|
// DIB Header (BITMAPINFOHEADER - 40 bytes)
|
|
write32(bmpOut, 40);
|
|
write32Signed(bmpOut, width);
|
|
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
|
|
write16(bmpOut, 1); // Color planes
|
|
write16(bmpOut, 2); // Bits per pixel (2 bits)
|
|
write32(bmpOut, 0); // BI_RGB (no compression)
|
|
write32(bmpOut, imageSize);
|
|
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
|
|
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
|
|
write32(bmpOut, 4); // colorsUsed
|
|
write32(bmpOut, 4); // colorsImportant
|
|
|
|
// Color Palette (4 colors x 4 bytes = 16 bytes)
|
|
// Format: Blue, Green, Red, Reserved (BGRA)
|
|
uint8_t palette[16] = {
|
|
0x00, 0x00, 0x00, 0x00, // Color 0: Black
|
|
0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85)
|
|
0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170)
|
|
0xFF, 0xFF, 0xFF, 0x00 // Color 3: White
|
|
};
|
|
for (const uint8_t i : palette) {
|
|
bmpOut.write(i);
|
|
}
|
|
}
|
|
|
|
// Callback function for picojpeg to read JPEG data
|
|
unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size,
|
|
unsigned char* pBytes_actually_read, void* pCallback_data) {
|
|
auto* context = static_cast<JpegReadContext*>(pCallback_data);
|
|
|
|
if (!context || !context->file) {
|
|
return PJPG_STREAM_READ_ERROR;
|
|
}
|
|
|
|
// Check if we need to refill our context buffer
|
|
if (context->bufferPos >= context->bufferFilled) {
|
|
context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer));
|
|
context->bufferPos = 0;
|
|
|
|
if (context->bufferFilled == 0) {
|
|
// EOF or error
|
|
*pBytes_actually_read = 0;
|
|
return 0; // Success (EOF is normal)
|
|
}
|
|
}
|
|
|
|
// Copy available bytes to picojpeg's buffer
|
|
const size_t available = context->bufferFilled - context->bufferPos;
|
|
const size_t toRead = available < buf_size ? available : buf_size;
|
|
|
|
memcpy(pBuf, context->buffer + context->bufferPos, toRead);
|
|
context->bufferPos += toRead;
|
|
*pBytes_actually_read = static_cast<unsigned char>(toRead);
|
|
|
|
return 0; // Success
|
|
}
|
|
|
|
// Internal implementation with configurable target size and bit depth
|
|
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
|
|
bool oneBit, bool crop) {
|
|
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
|
|
targetWidth, targetHeight);
|
|
|
|
// Setup context for picojpeg callback
|
|
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
|
|
|
|
// Initialize picojpeg decoder
|
|
pjpeg_image_info_t imageInfo;
|
|
const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
|
if (status != 0) {
|
|
Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status);
|
|
return false;
|
|
}
|
|
|
|
Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width,
|
|
imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
|
|
|
|
// Safety limits to prevent memory issues on ESP32
|
|
constexpr int MAX_IMAGE_WIDTH = 2048;
|
|
constexpr int MAX_IMAGE_HEIGHT = 3072;
|
|
constexpr int MAX_MCU_ROW_BYTES = 65536;
|
|
|
|
if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
|
|
Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width,
|
|
imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
|
|
return false;
|
|
}
|
|
|
|
// Calculate output dimensions (pre-scale to fit display exactly)
|
|
int outWidth = imageInfo.m_width;
|
|
int outHeight = imageInfo.m_height;
|
|
// Use fixed-point scaling (16.16) for sub-pixel accuracy
|
|
uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point
|
|
uint32_t scaleY_fp = 65536;
|
|
bool needsScaling = false;
|
|
|
|
if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) {
|
|
// Calculate scale to fit within target dimensions while maintaining aspect ratio
|
|
const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
|
|
const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
|
|
// We scale to the smaller dimension, so we can potentially crop later.
|
|
float scale = 1.0;
|
|
if (crop) { // if we will crop, scale to the smaller dimension
|
|
scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
|
|
} else { // else, scale to the larger dimension to fit
|
|
scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
|
|
}
|
|
|
|
outWidth = static_cast<int>(imageInfo.m_width * scale);
|
|
outHeight = static_cast<int>(imageInfo.m_height * scale);
|
|
|
|
// Ensure at least 1 pixel
|
|
if (outWidth < 1) outWidth = 1;
|
|
if (outHeight < 1) outHeight = 1;
|
|
|
|
// Calculate fixed-point scale factors (source pixels per output pixel)
|
|
// scaleX_fp = (srcWidth << 16) / outWidth
|
|
scaleX_fp = (static_cast<uint32_t>(imageInfo.m_width) << 16) / outWidth;
|
|
scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
|
|
needsScaling = true;
|
|
|
|
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width,
|
|
imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight);
|
|
}
|
|
|
|
// Write BMP header with output dimensions
|
|
int bytesPerRow;
|
|
if (USE_8BIT_OUTPUT && !oneBit) {
|
|
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
|
|
bytesPerRow = (outWidth + 3) / 4 * 4;
|
|
} else if (oneBit) {
|
|
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
|
|
bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel
|
|
} else {
|
|
writeBmpHeader2bit(bmpOut, outWidth, outHeight);
|
|
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
|
|
}
|
|
|
|
// Allocate row buffer
|
|
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
|
|
if (!rowBuffer) {
|
|
Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis());
|
|
return false;
|
|
}
|
|
|
|
// Allocate a buffer for one MCU row worth of grayscale pixels
|
|
// This is the minimal memory needed for streaming conversion
|
|
const int mcuPixelHeight = imageInfo.m_MCUHeight;
|
|
const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight;
|
|
|
|
// Validate MCU row buffer size before allocation
|
|
if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
|
|
Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels,
|
|
MAX_MCU_ROW_BYTES);
|
|
free(rowBuffer);
|
|
return false;
|
|
}
|
|
|
|
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
|
|
if (!mcuRowBuffer) {
|
|
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels);
|
|
free(rowBuffer);
|
|
return false;
|
|
}
|
|
|
|
// Create ditherer if enabled
|
|
// Use OUTPUT dimensions for dithering (after prescaling)
|
|
AtkinsonDitherer* atkinsonDitherer = nullptr;
|
|
FloydSteinbergDitherer* fsDitherer = nullptr;
|
|
Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
|
|
|
|
if (oneBit) {
|
|
// For 1-bit output, use Atkinson dithering for better quality
|
|
atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth);
|
|
} else if (!USE_8BIT_OUTPUT) {
|
|
if (USE_ATKINSON) {
|
|
atkinsonDitherer = new AtkinsonDitherer(outWidth);
|
|
} else if (USE_FLOYD_STEINBERG) {
|
|
fsDitherer = new FloydSteinbergDitherer(outWidth);
|
|
}
|
|
}
|
|
|
|
// For scaling: accumulate source rows into scaled output rows
|
|
// We need to track which source Y maps to which output Y
|
|
// Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format)
|
|
uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums)
|
|
uint16_t* rowCount = nullptr; // Count of source pixels accumulated per output X
|
|
int currentOutY = 0; // Current output row being accumulated
|
|
uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point)
|
|
|
|
if (needsScaling) {
|
|
rowAccum = new uint32_t[outWidth]();
|
|
rowCount = new uint16_t[outWidth]();
|
|
nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1)
|
|
}
|
|
|
|
// Process MCUs row-by-row and write to BMP as we go (top-down)
|
|
const int mcuPixelWidth = imageInfo.m_MCUWidth;
|
|
|
|
for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) {
|
|
// Clear the MCU row buffer
|
|
memset(mcuRowBuffer, 0, mcuRowPixels);
|
|
|
|
// Decode one row of MCUs
|
|
for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) {
|
|
const unsigned char mcuStatus = pjpeg_decode_mcu();
|
|
if (mcuStatus != 0) {
|
|
if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
|
|
Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY);
|
|
} else {
|
|
Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY,
|
|
mcuStatus);
|
|
}
|
|
free(mcuRowBuffer);
|
|
free(rowBuffer);
|
|
return false;
|
|
}
|
|
|
|
// picojpeg stores MCU data in 8x8 blocks
|
|
// Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128
|
|
for (int blockY = 0; blockY < mcuPixelHeight; blockY++) {
|
|
for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
|
|
const int pixelX = mcuX * mcuPixelWidth + blockX;
|
|
if (pixelX >= imageInfo.m_width) continue;
|
|
|
|
// Calculate proper block offset for picojpeg buffer
|
|
const int blockCol = blockX / 8;
|
|
const int blockRow = blockY / 8;
|
|
const int localX = blockX % 8;
|
|
const int localY = blockY % 8;
|
|
const int blocksPerRow = mcuPixelWidth / 8;
|
|
const int blockIndex = blockRow * blocksPerRow + blockCol;
|
|
const int pixelOffset = blockIndex * 64 + localY * 8 + localX;
|
|
|
|
uint8_t gray;
|
|
if (imageInfo.m_comps == 1) {
|
|
gray = imageInfo.m_pMCUBufR[pixelOffset];
|
|
} else {
|
|
const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset];
|
|
const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset];
|
|
const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset];
|
|
gray = (r * 25 + g * 50 + b * 25) / 100;
|
|
}
|
|
|
|
mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process source rows from this MCU row
|
|
const int startRow = mcuY * mcuPixelHeight;
|
|
const int endRow = (mcuY + 1) * mcuPixelHeight;
|
|
|
|
for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) {
|
|
const int bufferY = y - startRow;
|
|
|
|
if (!needsScaling) {
|
|
// No scaling - direct output (1:1 mapping)
|
|
memset(rowBuffer, 0, bytesPerRow);
|
|
|
|
if (USE_8BIT_OUTPUT && !oneBit) {
|
|
for (int x = 0; x < outWidth; x++) {
|
|
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
|
|
rowBuffer[x] = adjustPixel(gray);
|
|
}
|
|
} else if (oneBit) {
|
|
// 1-bit output with Atkinson dithering for better quality
|
|
for (int x = 0; x < outWidth; x++) {
|
|
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
|
|
const uint8_t bit =
|
|
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y);
|
|
// Pack 1-bit value: MSB first, 8 pixels per byte
|
|
const int byteIndex = x / 8;
|
|
const int bitOffset = 7 - (x % 8);
|
|
rowBuffer[byteIndex] |= (bit << bitOffset);
|
|
}
|
|
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
|
|
} else {
|
|
// 2-bit output
|
|
for (int x = 0; x < outWidth; x++) {
|
|
const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]);
|
|
uint8_t twoBit;
|
|
if (atkinsonDitherer) {
|
|
twoBit = atkinsonDitherer->processPixel(gray, x);
|
|
} else if (fsDitherer) {
|
|
twoBit = fsDitherer->processPixel(gray, x);
|
|
} else {
|
|
twoBit = quantize(gray, x, y);
|
|
}
|
|
const int byteIndex = (x * 2) / 8;
|
|
const int bitOffset = 6 - ((x * 2) % 8);
|
|
rowBuffer[byteIndex] |= (twoBit << bitOffset);
|
|
}
|
|
if (atkinsonDitherer)
|
|
atkinsonDitherer->nextRow();
|
|
else if (fsDitherer)
|
|
fsDitherer->nextRow();
|
|
}
|
|
bmpOut.write(rowBuffer, bytesPerRow);
|
|
} else {
|
|
// Fixed-point area averaging for exact fit scaling
|
|
// For each output pixel X, accumulate source pixels that map to it
|
|
// srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16)
|
|
const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width;
|
|
|
|
for (int outX = 0; outX < outWidth; outX++) {
|
|
// Calculate source X range for this output pixel
|
|
const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16;
|
|
const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16;
|
|
|
|
// Accumulate all source pixels in this range
|
|
int sum = 0;
|
|
int count = 0;
|
|
for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) {
|
|
sum += srcRow[srcX];
|
|
count++;
|
|
}
|
|
|
|
// Handle edge case: if no pixels in range, use nearest
|
|
if (count == 0 && srcXStart < imageInfo.m_width) {
|
|
sum = srcRow[srcXStart];
|
|
count = 1;
|
|
}
|
|
|
|
rowAccum[outX] += sum;
|
|
rowCount[outX] += count;
|
|
}
|
|
|
|
// Check if we've crossed into the next output row
|
|
// Current source Y in fixed point: y << 16
|
|
const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
|
|
|
|
// Output row when source Y crosses the boundary
|
|
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
|
|
memset(rowBuffer, 0, bytesPerRow);
|
|
|
|
if (USE_8BIT_OUTPUT && !oneBit) {
|
|
for (int x = 0; x < outWidth; x++) {
|
|
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
|
|
rowBuffer[x] = adjustPixel(gray);
|
|
}
|
|
} else if (oneBit) {
|
|
// 1-bit output with Atkinson dithering for better quality
|
|
for (int x = 0; x < outWidth; x++) {
|
|
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
|
|
const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x)
|
|
: quantize1bit(gray, x, currentOutY);
|
|
// Pack 1-bit value: MSB first, 8 pixels per byte
|
|
const int byteIndex = x / 8;
|
|
const int bitOffset = 7 - (x % 8);
|
|
rowBuffer[byteIndex] |= (bit << bitOffset);
|
|
}
|
|
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
|
|
} else {
|
|
// 2-bit output
|
|
for (int x = 0; x < outWidth; x++) {
|
|
const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
|
|
uint8_t twoBit;
|
|
if (atkinsonDitherer) {
|
|
twoBit = atkinsonDitherer->processPixel(gray, x);
|
|
} else if (fsDitherer) {
|
|
twoBit = fsDitherer->processPixel(gray, x);
|
|
} else {
|
|
twoBit = quantize(gray, x, currentOutY);
|
|
}
|
|
const int byteIndex = (x * 2) / 8;
|
|
const int bitOffset = 6 - ((x * 2) % 8);
|
|
rowBuffer[byteIndex] |= (twoBit << bitOffset);
|
|
}
|
|
if (atkinsonDitherer)
|
|
atkinsonDitherer->nextRow();
|
|
else if (fsDitherer)
|
|
fsDitherer->nextRow();
|
|
}
|
|
|
|
bmpOut.write(rowBuffer, bytesPerRow);
|
|
currentOutY++;
|
|
|
|
// Reset accumulators for next output row
|
|
memset(rowAccum, 0, outWidth * sizeof(uint32_t));
|
|
memset(rowCount, 0, outWidth * sizeof(uint16_t));
|
|
|
|
// Update boundary for next output row
|
|
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
if (rowAccum) {
|
|
delete[] rowAccum;
|
|
}
|
|
if (rowCount) {
|
|
delete[] rowCount;
|
|
}
|
|
if (atkinsonDitherer) {
|
|
delete atkinsonDitherer;
|
|
}
|
|
if (fsDitherer) {
|
|
delete fsDitherer;
|
|
}
|
|
if (atkinson1BitDitherer) {
|
|
delete atkinson1BitDitherer;
|
|
}
|
|
free(mcuRowBuffer);
|
|
free(rowBuffer);
|
|
|
|
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis());
|
|
return true;
|
|
}
|
|
|
|
// Core function: Convert JPEG file to 2-bit BMP (uses default target size)
|
|
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) {
|
|
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop);
|
|
}
|
|
|
|
// Convert with custom target size (for thumbnails, 2-bit)
|
|
bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
|
|
int targetMaxHeight) {
|
|
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
|
|
}
|
|
|
|
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
|
|
bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
|
|
int targetMaxHeight) {
|
|
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true, false);
|
|
}
|