Files
immich-frame/src/display_manager.cpp
cottongin 51756be080 fix: eliminate diagonal dither pattern from display driver
Root cause: Panel_ED2208's epd_quality mode applies _dither_row_rgb_pair
(diagonal bias pattern with dither=140) during _exec_transfer(). Since our
ImagePipeline already does proper Floyd-Steinberg dithering, the driver's
additional dithering was creating visible diagonal artifacts.

Fix: setEpdMode(epd_fastest) selects _dither_row_none (clean nearest-color
lookup, no spatial bias). On Panel_ED2208 this only affects the dither
function — NOT refresh quality or waveform.

Also: default dither_noise setting to OFF since error scatter was targeting
the wrong layer (it made gradient areas fuzzier without fixing the real
problem in the display driver).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:52:22 -04:00

373 lines
13 KiB
C++

#include "display_manager.h"
#include "power_manager.h"
#include <M5Unified.h>
// Map 6-color palette indices to RGB565 for M5GFX
static const uint16_t PALETTE_565[6] = {
0x0000, // Black
0xFFFF, // White
0xF800, // Red (approximate)
0x07E0, // Green (approximate)
0x001F, // Blue (approximate)
0xFFE0 // Yellow (approximate)
};
void DisplayManager::begin(PowerManager& power) {
_power = &power;
_display = &M5.Display;
// Ensure no automatic e-ink refreshes happen
_display->setAutoDisplay(false);
// Set rotation for landscape (device physically rotated)
_display->setRotation(3);
// Disable the panel driver's internal dithering (epd_quality applies a diagonal
// bias pattern via _dither_row_rgb_pair). Since our ImagePipeline already does
// proper Floyd-Steinberg dithering to the 6-color palette, the driver should
// just do a clean nearest-color lookup with no additional spatial bias.
// On Panel_ED2208 this only affects the dither function — NOT refresh quality.
_display->setEpdMode(epd_mode_t::epd_fastest);
Serial.printf("[display] Initialized: %dx%d, rotation=%d, epd_mode=fastest (no driver dither)\n",
_display->width(), _display->height(), _display->getRotation());
}
void DisplayManager::showSetupScreen() {
_power->enableEPDPower();
// Batch all drawing — nothing hits the panel until display() at the end
_display->startWrite();
_display->fillScreen(TFT_WHITE);
// Generate QR code for WiFi auto-connect
const char* qrData = "WIFI:T:nopass;S:PaperColor-Setup;;";
QRCode qrcode;
uint8_t qrcodeData[qrcode_getBufferSize(6)];
qrcode_initText(&qrcode, qrcodeData, 6, ECC_LOW, qrData);
// Draw QR code centered vertically, left-of-center horizontally
int qrSize = qrcode.size;
int scale = 4;
int qrPixels = qrSize * scale;
int qrX = (DISPLAY_WIDTH / 3) - (qrPixels / 2);
int qrY = (DISPLAY_HEIGHT - qrPixels) / 2;
for (int y = 0; y < qrSize; y++) {
for (int x = 0; x < qrSize; x++) {
uint16_t color = qrcode_getModule(&qrcode, x, y) ? TFT_BLACK : TFT_WHITE;
_display->fillRect(qrX + x * scale, qrY + y * scale, scale, scale, color);
}
}
// Text instructions to the right of QR code
int textX = DISPLAY_WIDTH / 2 + 40;
_display->setTextColor(TFT_BLACK);
_display->setTextDatum(middle_left);
_display->setTextSize(2);
_display->drawString("PaperColor", textX, DISPLAY_HEIGHT / 2 - 60);
_display->setTextSize(1);
_display->drawString("Scan QR to connect", textX, DISPLAY_HEIGHT / 2 - 20);
_display->drawString("or join WiFi:", textX, DISPLAY_HEIGHT / 2 + 10);
_display->setTextSize(1.5);
_display->drawString("PaperColor-Setup", textX, DISPLAY_HEIGHT / 2 + 40);
_display->setTextSize(1);
_display->drawString("Then open:", textX, DISPLAY_HEIGHT / 2 + 70);
_display->drawString("192.168.4.1", textX, DISPLAY_HEIGHT / 2 + 95);
_display->endWrite();
// Single e-ink refresh — the only panel update during boot
triggerRefresh();
_power->disableEPDPower();
}
void DisplayManager::showImage(const ProcessedImage& img) {
if (!img.valid || img.framebuffer == nullptr) {
Serial.println("[display] Invalid image — skipping");
return;
}
_power->enableEPDPower();
Serial.println("[display] Writing framebuffer to e-ink...");
// Write pixel by pixel using palette-mapped colors
_display->startWrite();
for (uint16_t y = 0; y < img.height; y++) {
for (uint16_t x = 0; x < img.width; x++) {
uint8_t colorIdx = img.framebuffer[y * img.width + x];
_display->writePixel(x, y, PALETTE_565[colorIdx]);
}
}
_display->endWrite();
// Do NOT refresh here — caller should add metadata overlay then call refresh()
}
void DisplayManager::showMessage(const char* title, const char* body) {
_power->enableEPDPower();
_display->startWrite();
_display->fillScreen(TFT_WHITE);
_display->setTextColor(TFT_BLACK);
_display->setTextDatum(middle_center);
_display->setTextSize(2);
_display->drawString(title, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 - 30);
_display->setTextSize(1);
_display->drawString(body, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 + 20);
_display->endWrite();
triggerRefresh();
_power->disableEPDPower();
}
void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
const String& dateFmt, const String& timeFmt) {
showMetadataInRegion(info, metaFlags, pos, 0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, dateFmt, timeFmt);
}
void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
uint16_t regionX, uint16_t regionY,
uint16_t regionW, uint16_t regionH,
const String& dateFmt, const String& timeFmt) {
if (metaFlags == 0) return;
// Build metadata string
String metaText = "";
// Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime
if (((metaFlags & META_DATE) || (metaFlags & META_TIME)) && info.dateTime.length() >= 19) {
struct tm t = {};
sscanf(info.dateTime.c_str(), "%d-%d-%dT%d:%d:%d",
&t.tm_year, &t.tm_mon, &t.tm_mday,
&t.tm_hour, &t.tm_min, &t.tm_sec);
t.tm_year -= 1900;
t.tm_mon -= 1;
char buf[64];
if ((metaFlags & META_DATE) && (metaFlags & META_TIME)) {
String combinedFmt = dateFmt + " " + timeFmt;
strftime(buf, sizeof(buf), combinedFmt.c_str(), &t);
metaText += buf;
} else if (metaFlags & META_DATE) {
strftime(buf, sizeof(buf), dateFmt.c_str(), &t);
metaText += buf;
} else {
strftime(buf, sizeof(buf), timeFmt.c_str(), &t);
metaText += buf;
}
} else if ((metaFlags & META_DATE) && info.dateTime.length() >= 10) {
// Fallback if datetime is too short for full parse
metaText += info.dateTime.substring(0, 10);
}
if ((metaFlags & META_LOCATION) && info.city.length() > 0) {
if (metaText.length() > 0) metaText += " | ";
metaText += info.city;
}
if ((metaFlags & META_PEOPLE) && !info.people.empty()) {
if (metaText.length() > 0) metaText += " | ";
for (size_t i = 0; i < info.people.size(); i++) {
if (i > 0) metaText += ", ";
metaText += info.people[i];
}
}
if ((metaFlags & META_ALBUM)) {
// Album name would need to be passed separately
}
if ((metaFlags & META_CAMERA) && info.camera.length() > 0) {
if (metaText.length() > 0) metaText += " | ";
metaText += info.camera;
}
if (metaText.length() == 0) return;
_display->setTextSize(1.5f);
_display->setTextColor(TFT_WHITE);
switch (pos) {
case MetaPosition::CaptionBottom:
case MetaPosition::CaptionTop: {
// Bar spanning the region width, positioned at region top or bottom
uint16_t barH = 36;
uint16_t barY = (pos == MetaPosition::CaptionTop)
? regionY
: regionY + regionH - barH;
_display->fillRect(regionX, barY, regionW, barH, TFT_BLACK);
_display->setTextDatum(middle_center);
_display->drawString(metaText.c_str(), regionX + regionW / 2, barY + barH / 2);
break;
}
case MetaPosition::OverlayTopLeft:
case MetaPosition::OverlayTopRight:
case MetaPosition::OverlayBottomLeft:
case MetaPosition::OverlayBottomRight: {
static constexpr uint16_t MARGIN = 8;
static constexpr uint16_t PAD_X = 8;
static constexpr uint16_t PAD_Y = 5;
static constexpr uint16_t TEXT_H = 20;
uint16_t textW = _display->textWidth(metaText.c_str());
uint16_t badgeW = min((uint16_t)(textW + PAD_X * 2), regionW);
uint16_t badgeH = TEXT_H + PAD_Y * 2;
uint16_t bx, by;
switch (pos) {
case MetaPosition::OverlayTopLeft:
bx = regionX + MARGIN;
by = regionY + MARGIN;
break;
case MetaPosition::OverlayTopRight:
bx = regionX + regionW - badgeW - MARGIN;
by = regionY + MARGIN;
break;
case MetaPosition::OverlayBottomLeft:
bx = regionX + MARGIN;
by = regionY + regionH - badgeH - MARGIN;
break;
case MetaPosition::OverlayBottomRight:
bx = regionX + regionW - badgeW - MARGIN;
by = regionY + regionH - badgeH - MARGIN;
break;
default:
bx = regionX + MARGIN;
by = regionY + MARGIN;
break;
}
_display->fillRect(bx, by, badgeW, badgeH, TFT_BLACK);
_display->setTextDatum(middle_left);
_display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2);
break;
}
}
}
void DisplayManager::drawBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
_display->fillRect(x, y, w, h, TFT_BLACK);
}
void DisplayManager::showSleepIndicator(MetaPosition metaPos) {
_power->enableEPDPower();
static constexpr uint16_t MARGIN = 8;
static constexpr uint16_t PAD_X = 6;
static constexpr uint16_t PAD_Y = 4;
_display->setTextSize(1);
uint16_t textW = _display->textWidth("SLP");
uint16_t textH = 14;
uint16_t badgeW = textW + PAD_X * 2;
uint16_t badgeH = textH + PAD_Y * 2;
// Pick a corner opposite to metadata position
uint16_t bx, by;
switch (metaPos) {
case MetaPosition::OverlayTopLeft:
case MetaPosition::CaptionTop:
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = DISPLAY_HEIGHT - badgeH - MARGIN;
break;
case MetaPosition::OverlayTopRight:
bx = MARGIN;
by = DISPLAY_HEIGHT - badgeH - MARGIN;
break;
case MetaPosition::OverlayBottomLeft:
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = MARGIN;
break;
case MetaPosition::OverlayBottomRight:
case MetaPosition::CaptionBottom:
default:
bx = MARGIN;
by = MARGIN;
break;
}
drawBadge(bx, by, badgeW, badgeH);
_display->setTextColor(TFT_WHITE);
_display->setTextDatum(middle_center);
_display->drawString("SLP", bx + badgeW / 2, by + badgeH / 2);
}
void DisplayManager::showBatteryIndicator(uint8_t percent, MetaPosition metaPos) {
static constexpr uint16_t MARGIN = 8;
static constexpr uint16_t PAD_X = 6;
static constexpr uint16_t PAD_Y = 4;
// Battery icon dimensions
static constexpr uint16_t BATT_W = 20;
static constexpr uint16_t BATT_H = 10;
static constexpr uint16_t NUB_W = 3;
static constexpr uint16_t NUB_H = 5;
static constexpr uint16_t GAP = 4;
// Build percentage text
char pctText[5];
snprintf(pctText, sizeof(pctText), "%d%%", percent);
_display->setTextSize(1);
uint16_t textW = _display->textWidth(pctText);
uint16_t textH = 14;
// Total badge size: icon + gap + text + padding
uint16_t contentW = BATT_W + NUB_W + GAP + textW;
uint16_t contentH = max(BATT_H, textH);
uint16_t badgeW = contentW + PAD_X * 2;
uint16_t badgeH = contentH + PAD_Y * 2;
// Position: top-right by default, top-left if metadata occupies top-right
uint16_t bx, by;
if (metaPos == MetaPosition::OverlayTopRight) {
bx = MARGIN;
by = MARGIN;
} else {
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = MARGIN;
}
// Draw badge background
drawBadge(bx, by, badgeW, badgeH);
// Draw battery outline (white rect with 1px border)
uint16_t iconX = bx + PAD_X;
uint16_t iconY = by + (badgeH - BATT_H) / 2;
_display->drawRect(iconX, iconY, BATT_W, BATT_H, TFT_WHITE);
// Draw nub on right side of battery
uint16_t nubX = iconX + BATT_W;
uint16_t nubY = iconY + (BATT_H - NUB_H) / 2;
_display->fillRect(nubX, nubY, NUB_W, NUB_H, TFT_WHITE);
// Draw fill level inside battery (1px inset)
uint16_t fillMaxW = BATT_W - 2;
uint16_t fillW = (fillMaxW * percent) / 100;
if (fillW > 0) {
_display->fillRect(iconX + 1, iconY + 1, fillW, BATT_H - 2, TFT_WHITE);
}
// Draw percentage text
uint16_t textX = iconX + BATT_W + NUB_W + GAP;
_display->setTextColor(TFT_WHITE);
_display->setTextDatum(middle_left);
_display->drawString(pctText, textX, by + badgeH / 2);
}
void DisplayManager::refresh() {
unsigned long start = millis();
triggerRefresh();
unsigned long elapsed = millis() - start;
Serial.printf("[display] Refresh complete in %lu ms\n", elapsed);
_power->disableEPDPower();
}
void DisplayManager::triggerRefresh() {
// M5GFX handles e-ink refresh internally when using the EPD panel driver
// The display() call triggers the actual e-ink refresh cycle (10-20s)
_display->display();
}
uint16_t DisplayManager::paletteToColor565(uint8_t index) {
if (index >= DISPLAY_COLORS) return 0;
return PALETTE_565[index];
}