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>
This commit is contained in:
2026-08-03 19:52:22 -04:00
parent fd7643db6a
commit 065d80ca89
20 changed files with 1100 additions and 138 deletions

View File

@@ -87,7 +87,8 @@ async function renderAlbums(el) {
async function renderSlideshow(el) {
const settings = await api('/api/settings');
const intervals = [1, 5, 15, 30, 60];
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted'];
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted',
'Weighted Recent', 'Weighted Oldest'];
el.innerHTML = `
<div class="card">
<h2>Interval</h2>
@@ -131,6 +132,16 @@ async function renderDisplay(el) {
</select>
</div>
</div>
<div class="card">
<h2>Image Processing</h2>
<div class="field">
<select id="pipelineMode">
<option value="0" ${settings.pipeline_mode===0?'selected':''}>Dynamic (vivid colors)</option>
<option value="1" ${settings.pipeline_mode===1?'selected':''}>Balanced (preserve tones)</option>
<option value="2" ${settings.pipeline_mode===2?'selected':''}>None (no processing)</option>
</select>
</div>
</div>
<div class="card">
<h2>Metadata Overlay</h2>
<ul class="checkbox-list">
@@ -163,16 +174,24 @@ async function renderDisplay(el) {
</select>
</div>
</div>
<div class="card">
<h2>Battery Indicator</h2>
<ul class="checkbox-list">
<li><input type="checkbox" id="showBattery" ${settings.show_battery?'checked':''}><span>Show battery level on display</span></li>
</ul>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveDisplay">Save</button></div>`;
document.getElementById('saveDisplay').onclick = async () => {
let flags = 0;
el.querySelectorAll('[data-flag]').forEach(cb => { if (cb.checked) flags |= parseInt(cb.dataset.flag); });
await api('/api/settings', { method: 'POST', body: JSON.stringify({
img_quality: parseInt(document.getElementById('imgQuality').value),
pipeline_mode: parseInt(document.getElementById('pipelineMode').value),
meta_flags: flags,
meta_pos: parseInt(document.getElementById('metaPos').value),
date_fmt: document.getElementById('dateFmt').value,
time_fmt: document.getElementById('timeFmt').value
time_fmt: document.getElementById('timeFmt').value,
show_battery: document.getElementById('showBattery').checked ? 1 : 0
})});
toast('Display settings saved');
};

View File

@@ -16,6 +16,7 @@ build_flags =
-DCORE_DEBUG_LEVEL=3
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DARDUINO_LOOP_STACK_SIZE=32768
lib_deps =
m5stack/M5Unified @ ^0.2.2
m5stack/M5GFX @ ^0.2.5

83
src/blue_noise.h Normal file
View File

@@ -0,0 +1,83 @@
#pragma once
#ifdef ARDUINO
#include <Arduino.h>
#else
#ifndef PROGMEM
#define PROGMEM
#endif
#ifndef pgm_read_byte
#define pgm_read_byte(addr) (*(const uint8_t*)(addr))
#endif
#include <cstdint>
#endif
// 64x64 blue noise texture generated via void-and-cluster algorithm.
// Used for dither threshold modulation to break up Floyd-Steinberg patterns.
// Values 0-255, spatially uniform frequency distribution.
static const uint8_t PROGMEM BLUE_NOISE_64[64 * 64] = {
0x8A, 0xF1, 0x42, 0xE3, 0x6C, 0x4E, 0xBA, 0xF1, 0x47, 0xCA, 0xB7, 0x32, 0x8C, 0x69, 0x09, 0xB0, 0x89, 0x0E, 0xF5, 0x21, 0x30, 0xC7, 0x6E, 0x10, 0xEA, 0xBC, 0x08, 0x9E, 0xF0, 0x29, 0x6B, 0xC6, 0x51, 0x16, 0xB5, 0x5B, 0x44, 0xA7, 0x68, 0x8D, 0x4E, 0xBF, 0x95, 0x10, 0x18, 0xEA, 0x8A, 0xD6, 0x27, 0x38, 0x93, 0xC9, 0x14, 0x8D, 0xF0, 0xB7, 0xD1, 0x53, 0x22, 0xDD, 0xC8, 0xA2, 0x61, 0xB4,
0x0A, 0x9E, 0x26, 0x84, 0x9B, 0xD0, 0x16, 0x88, 0x9A, 0x0B, 0x59, 0xD9, 0x20, 0xEE, 0x4E, 0xCC, 0x73, 0xD6, 0x54, 0xBE, 0x9B, 0xED, 0x41, 0xA2, 0x56, 0x7E, 0xDD, 0x34, 0x61, 0x13, 0xD7, 0x7F, 0x05, 0xED, 0x94, 0x0D, 0x7D, 0xCB, 0x3B, 0xE8, 0xB0, 0x20, 0xE5, 0x85, 0xCC, 0x01, 0x3D, 0x5D, 0xAD, 0xE2, 0x4F, 0xF6, 0xA9, 0x2F, 0x4C, 0x65, 0x17, 0x85, 0xB1, 0x32, 0x5A, 0x1D, 0x7D, 0xDF,
0x6D, 0xD1, 0x5E, 0xB8, 0x0C, 0xFD, 0x64, 0x34, 0xE5, 0x6D, 0xAA, 0x3C, 0x7D, 0xB8, 0x98, 0x38, 0x13, 0xA9, 0x3C, 0x80, 0x60, 0x17, 0x78, 0xD1, 0x2E, 0xAE, 0x4B, 0x73, 0xC2, 0x96, 0x39, 0xA0, 0xB9, 0x62, 0xD3, 0x33, 0xF1, 0x99, 0x0A, 0x73, 0x2D, 0x62, 0x46, 0x6C, 0xB1, 0x56, 0xA1, 0xFB, 0x6E, 0x0B, 0x7D, 0x18, 0x69, 0xDD, 0x81, 0xA4, 0x0E, 0xEC, 0x6C, 0x99, 0xF5, 0xB9, 0x2C, 0x48,
0xC5, 0x35, 0xED, 0x4B, 0x19, 0x7F, 0x44, 0xB5, 0xD3, 0x27, 0xF8, 0x92, 0x62, 0x15, 0xDE, 0x5E, 0xFA, 0x8F, 0xE7, 0x0D, 0xDB, 0xAB, 0x04, 0xFA, 0x93, 0x1A, 0xEC, 0x8C, 0x01, 0xFE, 0x56, 0xE3, 0x27, 0x41, 0x72, 0xB1, 0x54, 0x18, 0xBB, 0xD9, 0x9E, 0xEF, 0xC3, 0x33, 0xF4, 0x7C, 0x2B, 0xC2, 0x15, 0xD2, 0x9A, 0xBA, 0x43, 0xC5, 0x24, 0xE6, 0xBC, 0x47, 0xD5, 0x14, 0x41, 0x8B, 0xE7, 0xA8,
0x17, 0x8D, 0x77, 0xAA, 0xE0, 0xC4, 0x98, 0x12, 0x79, 0x50, 0x06, 0xC0, 0xE9, 0x48, 0x01, 0xC1, 0x6D, 0x23, 0x4D, 0xBA, 0x36, 0x87, 0xC5, 0x44, 0x61, 0xBD, 0x27, 0xCF, 0x42, 0xB0, 0x77, 0x1C, 0x8A, 0xF4, 0x9E, 0x14, 0xDF, 0x6B, 0x8A, 0x44, 0x12, 0x7E, 0x06, 0x9A, 0x1F, 0xDB, 0x48, 0x94, 0x83, 0x3A, 0x57, 0xEB, 0x04, 0x90, 0x5C, 0x38, 0x71, 0x8F, 0x01, 0x61, 0xC9, 0x6E, 0x0C, 0x5B,
0xF6, 0x02, 0xD9, 0x2C, 0x69, 0x07, 0xE9, 0x60, 0xA4, 0xC7, 0x85, 0x30, 0x9F, 0x77, 0xAA, 0x88, 0x2E, 0xCD, 0x9C, 0x70, 0xF5, 0x57, 0x28, 0x73, 0xDF, 0x83, 0x56, 0xA3, 0x6A, 0x2C, 0xDB, 0xC3, 0x4D, 0xCC, 0x08, 0x81, 0xC0, 0x34, 0xFC, 0x5C, 0xCB, 0xAF, 0x4E, 0xD4, 0x60, 0xB8, 0x08, 0xED, 0x61, 0xE3, 0xA5, 0x2E, 0x71, 0xFE, 0x9E, 0xD0, 0x20, 0xF7, 0xB3, 0x9D, 0xEF, 0x37, 0xBB, 0x98,
0x4A, 0xB5, 0x59, 0x9B, 0x3F, 0x86, 0x4D, 0x28, 0xF4, 0x39, 0xDF, 0x5C, 0x1D, 0xD4, 0x3A, 0xF2, 0x58, 0xE5, 0x09, 0xAF, 0x1E, 0x94, 0xE9, 0xA7, 0x0D, 0x39, 0xF7, 0x1D, 0xE3, 0x91, 0x0D, 0xA0, 0x6E, 0x2F, 0x5C, 0xEB, 0x49, 0xA4, 0x1F, 0x92, 0x2A, 0xF4, 0x73, 0x8E, 0x38, 0xA2, 0x75, 0xC8, 0x17, 0xB3, 0x1F, 0x8A, 0xCC, 0x47, 0x0F, 0xAE, 0x57, 0x7F, 0x2E, 0x4D, 0x7C, 0x22, 0xDE, 0x75,
0xD6, 0x83, 0x21, 0xC7, 0xF7, 0xA6, 0xD2, 0xBA, 0x8E, 0x0E, 0x72, 0xB0, 0xFE, 0x53, 0xB9, 0x0F, 0x98, 0x79, 0x38, 0x60, 0xD1, 0x48, 0xBE, 0x17, 0xD3, 0x9A, 0xB3, 0x79, 0x4A, 0xBE, 0x60, 0x3C, 0xF9, 0xB7, 0x94, 0xD5, 0x1A, 0x71, 0xDA, 0xBF, 0x68, 0x3E, 0x0D, 0xE3, 0x22, 0xFD, 0x52, 0x10, 0x45, 0x7B, 0xD7, 0x51, 0xAC, 0x15, 0x85, 0xE5, 0x3C, 0xDA, 0xBD, 0x1A, 0xCF, 0xA4, 0x43, 0x0F,
0xA9, 0x38, 0xEB, 0x6D, 0x10, 0x30, 0x76, 0x1E, 0x63, 0xD5, 0x49, 0x96, 0x27, 0x86, 0x6F, 0x18, 0xCA, 0x47, 0xBF, 0xF0, 0x7F, 0x30, 0x6C, 0x8B, 0x51, 0x68, 0x04, 0xC8, 0x34, 0xED, 0x87, 0xAE, 0x02, 0x79, 0x25, 0x3C, 0xAB, 0x87, 0x4F, 0x00, 0xAA, 0xDD, 0x99, 0xC4, 0x67, 0x84, 0xAC, 0xDF, 0x98, 0xF8, 0x68, 0x36, 0xEA, 0x61, 0xBE, 0x73, 0x07, 0x91, 0x66, 0xEC, 0x5B, 0x8C, 0xFC, 0x65,
0x13, 0xBE, 0x91, 0x47, 0xB1, 0x5A, 0xE3, 0x9F, 0x3E, 0xB6, 0xEB, 0x08, 0xC4, 0x3D, 0xEE, 0xA8, 0xDF, 0x8A, 0x14, 0xA5, 0x03, 0x9C, 0xFD, 0x0F, 0xE1, 0x41, 0xF2, 0x93, 0x5C, 0x12, 0x28, 0xE1, 0x4E, 0xCF, 0xED, 0x69, 0xBB, 0xE4, 0x37, 0xF0, 0x7B, 0x58, 0x29, 0x4C, 0xB4, 0x1A, 0x2F, 0x64, 0xC1, 0x26, 0x00, 0xB8, 0x90, 0x2A, 0xF2, 0x4F, 0xC9, 0x25, 0xA3, 0x3E, 0x05, 0xB6, 0x29, 0x7F,
0xD0, 0x55, 0x08, 0xDD, 0x80, 0xCA, 0x00, 0xFB, 0x87, 0x2A, 0x7E, 0x6B, 0xE1, 0x9F, 0x60, 0x06, 0x54, 0x2F, 0x6E, 0xE3, 0x42, 0xD7, 0x58, 0xAB, 0xC0, 0x81, 0x2A, 0xD7, 0xAB, 0x76, 0xC6, 0x64, 0x9A, 0x1C, 0x8B, 0x52, 0x0A, 0x28, 0x65, 0xA0, 0x11, 0xD1, 0x8B, 0xF1, 0x3B, 0xE7, 0xD1, 0x90, 0x3F, 0x82, 0xA1, 0xD4, 0x6E, 0x0B, 0x9B, 0x32, 0xAB, 0xFA, 0x81, 0xE0, 0x73, 0xCB, 0x4F, 0xE8,
0x9D, 0x76, 0xF9, 0x27, 0x97, 0x39, 0x6F, 0x51, 0xC2, 0x1A, 0xAA, 0x35, 0x4F, 0x1D, 0xD6, 0x7D, 0xB6, 0xFA, 0x93, 0x5B, 0xB5, 0x26, 0x75, 0x37, 0x1F, 0xA0, 0x6D, 0x10, 0x3F, 0xFC, 0x92, 0x32, 0xB2, 0x43, 0xC5, 0xA2, 0xF8, 0xCC, 0x91, 0xC2, 0x42, 0xB0, 0x17, 0x69, 0x9A, 0x7B, 0x56, 0x09, 0xF4, 0x5A, 0xE2, 0x38, 0x4E, 0xCC, 0x80, 0xDD, 0x60, 0x12, 0x4D, 0x31, 0xA7, 0x1E, 0x8F, 0x36,
0x1B, 0xB5, 0x41, 0x66, 0xD7, 0xBC, 0x22, 0x9C, 0xD8, 0x61, 0xF3, 0xCB, 0x8B, 0xBA, 0x2C, 0x97, 0x40, 0x15, 0xCF, 0x0B, 0x83, 0xC6, 0x96, 0xF1, 0xCC, 0x56, 0xEB, 0x8B, 0xBB, 0x51, 0x0A, 0xD9, 0xF2, 0x6B, 0x29, 0x75, 0x39, 0x7E, 0x55, 0x19, 0xFC, 0x71, 0xDD, 0x08, 0xBF, 0x23, 0xA6, 0xC9, 0x14, 0xB1, 0x77, 0x1B, 0xFC, 0xA5, 0x1F, 0x3F, 0x78, 0xC5, 0x97, 0xD0, 0x5E, 0xF5, 0xBB, 0x6B,
0x2A, 0xE9, 0x88, 0xA9, 0x19, 0x4D, 0xF0, 0x7C, 0x31, 0x46, 0x94, 0x05, 0x5C, 0xF9, 0x71, 0xE3, 0xC2, 0x65, 0xA8, 0x3B, 0xF5, 0x4E, 0x0E, 0x67, 0x43, 0x01, 0xA8, 0x34, 0xD4, 0x7F, 0x17, 0x5B, 0x84, 0x04, 0xD0, 0xE3, 0x1B, 0xA8, 0xE1, 0x04, 0x87, 0x51, 0xA2, 0x38, 0xF6, 0x4B, 0xE0, 0x6B, 0x42, 0x96, 0x2B, 0xC1, 0x8D, 0x59, 0xBB, 0xF3, 0xAF, 0x02, 0xE8, 0x26, 0x83, 0x08, 0x48, 0xD6,
0x5C, 0xC6, 0x03, 0x57, 0xE2, 0x8C, 0xAF, 0x09, 0xB9, 0xE3, 0x75, 0x17, 0xAF, 0x44, 0x21, 0x57, 0x01, 0x8B, 0xDE, 0x72, 0x24, 0xB0, 0xE5, 0x80, 0xDD, 0xB8, 0x6A, 0x21, 0x5F, 0xE8, 0xAF, 0xCB, 0xA2, 0x37, 0x93, 0x56, 0xB6, 0x47, 0x68, 0xD1, 0xB9, 0x12, 0xCB, 0x61, 0x8F, 0x75, 0x30, 0x8B, 0xB7, 0xEB, 0x66, 0xDC, 0x3C, 0x0E, 0x6D, 0x2D, 0x50, 0x8B, 0x6B, 0x43, 0xB2, 0xDC, 0x7B, 0xA3,
0x92, 0x3B, 0x77, 0xCC, 0x2F, 0x6A, 0x3E, 0xD0, 0x64, 0x12, 0xA4, 0xCE, 0xE9, 0x83, 0x9C, 0xB2, 0xF5, 0x31, 0x50, 0x9D, 0xCB, 0x62, 0x2B, 0xA2, 0x1D, 0x8E, 0xF7, 0xC8, 0x9D, 0x42, 0x2D, 0x71, 0x4B, 0xBC, 0xF5, 0x23, 0x8A, 0xF0, 0x2C, 0x9B, 0x40, 0x77, 0xED, 0x18, 0xAE, 0xD2, 0x10, 0xF3, 0x21, 0x53, 0x03, 0x9F, 0x7A, 0xEE, 0xD0, 0x9A, 0xE0, 0x1F, 0xBF, 0xFE, 0x9C, 0x39, 0x11, 0xF2,
0x23, 0xB3, 0xEC, 0x9E, 0x1E, 0xFD, 0x9A, 0x26, 0x87, 0xF4, 0x51, 0x39, 0x67, 0x1B, 0xDA, 0x3D, 0x7C, 0xC5, 0x16, 0xEF, 0x0C, 0x8F, 0x3D, 0xD7, 0x58, 0x31, 0x4C, 0x78, 0x07, 0x88, 0xFA, 0x0D, 0xE5, 0x13, 0x61, 0x74, 0xCA, 0x0D, 0x7D, 0xEA, 0x15, 0x93, 0x4F, 0xBD, 0x02, 0x47, 0xA1, 0x5D, 0xC7, 0x84, 0xD3, 0x4A, 0xB7, 0x31, 0x86, 0x09, 0x64, 0xA4, 0x2E, 0x57, 0x15, 0x73, 0xCA, 0x4E,
0x85, 0x0D, 0x64, 0x46, 0xB7, 0x78, 0x53, 0xDB, 0xA8, 0x2E, 0x7E, 0x99, 0xBA, 0x2C, 0xC8, 0x5C, 0x0F, 0xAA, 0x67, 0x82, 0x49, 0xC0, 0xFE, 0x6C, 0xB4, 0xCE, 0x14, 0xAD, 0xDE, 0xC0, 0x53, 0x9B, 0x7E, 0xB1, 0xD7, 0x42, 0xA3, 0x35, 0xBE, 0x5B, 0xB0, 0xE0, 0x2C, 0x6F, 0xFA, 0x86, 0xDC, 0x79, 0x3A, 0xA6, 0x28, 0xE7, 0x1D, 0x5B, 0xAD, 0x49, 0xC4, 0xE9, 0x76, 0xD2, 0x8F, 0xE4, 0x63, 0xBD,
0xF7, 0xD9, 0x33, 0x90, 0xE7, 0x16, 0xC5, 0x07, 0x46, 0xBF, 0xDD, 0x02, 0xFA, 0x4B, 0x77, 0xEF, 0x8D, 0xE6, 0x3A, 0xDA, 0xA4, 0x74, 0x17, 0x08, 0x7E, 0x95, 0xF3, 0x5D, 0x3A, 0x26, 0x69, 0xD5, 0x3D, 0x2C, 0x91, 0x00, 0xFD, 0x6A, 0xDB, 0x46, 0x06, 0x82, 0xCF, 0xA5, 0x59, 0x35, 0x22, 0xB8, 0x0B, 0xF9, 0x62, 0x95, 0x75, 0xCA, 0xFB, 0x23, 0x80, 0x3D, 0x0F, 0xB6, 0x4A, 0x01, 0xA7, 0x29,
0x59, 0xA2, 0x72, 0xD2, 0x0E, 0x86, 0x62, 0xF6, 0x7B, 0x5E, 0x16, 0x6B, 0x87, 0xAC, 0x0C, 0xA2, 0x24, 0x52, 0xCC, 0x04, 0x2D, 0xEB, 0xAE, 0x53, 0xE8, 0x41, 0x0E, 0x72, 0x8E, 0xEC, 0xA6, 0x1C, 0xBE, 0x5D, 0xE7, 0xAC, 0x52, 0x8D, 0x25, 0x9D, 0xF7, 0x65, 0x3F, 0x1E, 0xC4, 0x94, 0xE9, 0x4F, 0x6E, 0xCE, 0x43, 0xBE, 0x07, 0x8E, 0x36, 0xDE, 0x97, 0x5D, 0xF3, 0x9E, 0x32, 0xEE, 0x7C, 0x3F,
0x09, 0xC7, 0x25, 0x52, 0xA9, 0x40, 0xB5, 0x92, 0x12, 0xEB, 0xB1, 0xD3, 0x34, 0xE3, 0x62, 0xC2, 0x35, 0x70, 0xB3, 0x98, 0x60, 0x40, 0x91, 0xD4, 0x2D, 0xC0, 0xA0, 0xDB, 0xB2, 0x03, 0x7B, 0x46, 0xF8, 0x83, 0x24, 0x75, 0xC7, 0x19, 0xB9, 0x78, 0xC8, 0x10, 0x90, 0xEC, 0x76, 0x0A, 0xD2, 0xAB, 0x8C, 0x1E, 0x9E, 0x32, 0xF1, 0x50, 0x69, 0xA7, 0x05, 0xD4, 0x15, 0x83, 0x66, 0xBA, 0xD1, 0x97,
0xE9, 0x81, 0xB4, 0xFA, 0x6E, 0xDE, 0x29, 0xCC, 0x3A, 0x9F, 0x26, 0x55, 0x96, 0x1D, 0x43, 0xD6, 0x92, 0xFC, 0x13, 0x80, 0xE0, 0xBE, 0x14, 0x68, 0x87, 0x5B, 0x22, 0x4E, 0x32, 0xCD, 0x5A, 0xDD, 0x99, 0x0D, 0xCF, 0x47, 0x30, 0xEE, 0x5E, 0x3D, 0x2B, 0xAB, 0xDA, 0x4C, 0xB6, 0x65, 0x3E, 0x2C, 0xF1, 0x5A, 0xDF, 0x70, 0xAE, 0xD0, 0x1C, 0xBE, 0x7A, 0x4C, 0xAC, 0xCB, 0x25, 0x51, 0x1B, 0x6C,
0x2E, 0x4D, 0x1B, 0x37, 0x93, 0x00, 0x55, 0x70, 0xE5, 0x83, 0x45, 0xC6, 0x79, 0xF3, 0xB6, 0x7E, 0x07, 0x5B, 0xC9, 0x4A, 0x24, 0x71, 0xF9, 0x00, 0xAA, 0xE6, 0xC6, 0x6E, 0xF7, 0x89, 0x27, 0xAE, 0x35, 0x68, 0xB2, 0xE4, 0x85, 0xA4, 0xD3, 0x97, 0xF2, 0x55, 0x7F, 0x31, 0x21, 0xFE, 0xA1, 0x7C, 0x00, 0xC3, 0x84, 0x10, 0x29, 0x87, 0x3F, 0xEA, 0x2D, 0xF8, 0x6D, 0x39, 0xE0, 0x92, 0xFC, 0xAD,
0xDB, 0x9C, 0xE6, 0x5D, 0xC3, 0xF2, 0xA6, 0xBD, 0x0B, 0x62, 0xFD, 0xAA, 0x04, 0x65, 0x2C, 0x4F, 0xE7, 0xA3, 0x2F, 0xF0, 0xAF, 0x9B, 0x50, 0xC9, 0x33, 0x76, 0x0C, 0x92, 0x43, 0xB8, 0x1A, 0x72, 0xF2, 0x52, 0x90, 0x09, 0x63, 0x14, 0x4D, 0x02, 0x70, 0x16, 0xC3, 0x99, 0xDE, 0x85, 0x58, 0xCD, 0x4C, 0xAC, 0x38, 0xEE, 0x54, 0xDC, 0x9F, 0x59, 0x8D, 0x1E, 0x9B, 0xB8, 0x08, 0x7A, 0x3E, 0x5F,
0x04, 0x87, 0xB9, 0x79, 0x21, 0x81, 0x3D, 0x17, 0x8E, 0xD8, 0x1F, 0x35, 0xD5, 0xA1, 0xDF, 0x8F, 0x15, 0xC0, 0x67, 0x88, 0x3B, 0x1C, 0xD8, 0x8F, 0x45, 0xEF, 0xA2, 0x19, 0xE1, 0x60, 0xC8, 0x95, 0xD6, 0x16, 0xC1, 0x3B, 0xF5, 0xC4, 0x80, 0xE4, 0xB1, 0xD8, 0x62, 0x43, 0x06, 0xB2, 0x13, 0xE7, 0x26, 0x8F, 0x61, 0xCA, 0x74, 0xB9, 0x03, 0x6A, 0xC9, 0xDC, 0x45, 0x5F, 0xEF, 0xA7, 0x23, 0xC2,
0x6A, 0x40, 0x13, 0xD3, 0x49, 0xE8, 0x9D, 0xCD, 0x4D, 0xB1, 0x76, 0x58, 0x89, 0x3F, 0x1F, 0xB3, 0x74, 0x45, 0x11, 0xDD, 0xC3, 0x5E, 0x79, 0x24, 0xBA, 0x59, 0xD6, 0x12, 0xA8, 0x7E, 0x31, 0x4C, 0x03, 0x7F, 0xE0, 0x9D, 0x72, 0xAB, 0x2A, 0x40, 0x8D, 0x0F, 0xA3, 0xF6, 0x70, 0xD1, 0x37, 0x6D, 0xBE, 0xF9, 0x0C, 0x99, 0x41, 0x16, 0xFD, 0x36, 0xB0, 0x0D, 0x80, 0x2A, 0xCE, 0x4D, 0x8C, 0xD6,
0xB2, 0xED, 0x58, 0xA7, 0x2B, 0x64, 0x11, 0x6C, 0xF3, 0x2D, 0x9E, 0xE7, 0xC5, 0x6D, 0xFA, 0x54, 0xCA, 0xEE, 0x98, 0x77, 0x06, 0xE8, 0x9F, 0xF6, 0x0A, 0x88, 0x6D, 0x49, 0xFE, 0x23, 0xD2, 0xEE, 0xB0, 0x63, 0x2B, 0x4A, 0x1D, 0x56, 0xCA, 0xF7, 0x5D, 0x34, 0xC7, 0x25, 0x84, 0x52, 0xA8, 0x95, 0x43, 0x7D, 0x19, 0xAF, 0xE4, 0x88, 0xA7, 0x79, 0x4C, 0xE8, 0xBE, 0x98, 0x70, 0x14, 0xEA, 0x2F,
0x97, 0x0B, 0x75, 0xFE, 0x92, 0xC0, 0xDF, 0x86, 0x03, 0xC1, 0x47, 0x15, 0x09, 0xAC, 0x30, 0x83, 0x02, 0x18, 0xAB, 0x55, 0xB5, 0x48, 0x31, 0x64, 0xA9, 0x2B, 0xCD, 0x96, 0xBD, 0x5A, 0x70, 0x9B, 0x3D, 0x8C, 0xFB, 0xB7, 0xD7, 0x8C, 0x09, 0xA1, 0x7A, 0xE1, 0x4C, 0x95, 0xE4, 0x0C, 0xF4, 0x1E, 0xDB, 0x56, 0xCD, 0x6B, 0x30, 0x4E, 0xD0, 0x15, 0x91, 0x62, 0x32, 0xFA, 0x01, 0xBA, 0x57, 0x7C,
0x25, 0xDE, 0xC4, 0x35, 0x0E, 0x52, 0xAE, 0x37, 0x5C, 0xDA, 0x92, 0x7F, 0xF1, 0x60, 0xD4, 0x96, 0xE2, 0x64, 0x3A, 0xFC, 0x16, 0xCE, 0x91, 0xDE, 0xC2, 0x41, 0xED, 0x03, 0x38, 0x8B, 0x0D, 0xC1, 0x20, 0xCA, 0x0A, 0x7A, 0x32, 0xE7, 0x6D, 0x18, 0xBE, 0x12, 0xB0, 0x68, 0x3A, 0xBC, 0x5F, 0x30, 0xB2, 0x89, 0x05, 0xF6, 0xBB, 0x5F, 0x08, 0xF3, 0xC3, 0x21, 0xAB, 0x50, 0x88, 0xDB, 0xA5, 0x42,
0xB7, 0x4D, 0x64, 0x89, 0xD8, 0x78, 0x22, 0xF8, 0xA2, 0x6E, 0x28, 0xB9, 0x4E, 0xA4, 0x23, 0x47, 0xB4, 0x7D, 0xD7, 0x8A, 0x6A, 0x0C, 0x7B, 0x50, 0x1A, 0x82, 0x55, 0x74, 0xA9, 0xD8, 0xEB, 0x53, 0x6B, 0xE5, 0x4F, 0x9E, 0x60, 0xAF, 0x46, 0xD4, 0x54, 0x89, 0xF2, 0x1D, 0xA3, 0x80, 0xCF, 0x75, 0xEE, 0x3D, 0x97, 0x14, 0x80, 0xDD, 0x9C, 0x71, 0x40, 0xE2, 0x6A, 0xCB, 0x39, 0x1F, 0x65, 0xF8,
0x84, 0x9D, 0x1E, 0xAC, 0x41, 0xE8, 0x95, 0x46, 0xC9, 0x1B, 0xEC, 0x3A, 0xCD, 0x19, 0x76, 0xF4, 0x33, 0x1B, 0xC3, 0x29, 0xA6, 0xE8, 0xBA, 0x27, 0xFA, 0x9F, 0xB5, 0xE6, 0x21, 0x48, 0x2D, 0x81, 0xA5, 0x34, 0xB5, 0x25, 0xEE, 0x1B, 0x93, 0xFD, 0x01, 0x71, 0xC8, 0x2D, 0xDC, 0x46, 0x03, 0x9B, 0x20, 0x68, 0xD7, 0xA7, 0x2E, 0x45, 0xB6, 0x28, 0x86, 0xA1, 0x0B, 0x7D, 0xED, 0x97, 0xC8, 0x07,
0xD2, 0x2F, 0xF4, 0xC6, 0x04, 0x5F, 0xB8, 0x0C, 0x84, 0x58, 0xAB, 0x7C, 0x65, 0xE4, 0x91, 0xC1, 0x5F, 0x9B, 0x4C, 0xEF, 0x58, 0x3D, 0x9D, 0x66, 0xD0, 0x33, 0x10, 0x65, 0xCE, 0x9C, 0xB8, 0xF4, 0x01, 0xD2, 0x6F, 0x8E, 0xCE, 0x3D, 0x7F, 0xB4, 0x32, 0xA9, 0x44, 0x63, 0x8F, 0xF9, 0x55, 0xC7, 0xE2, 0x4B, 0xBD, 0x5B, 0xF1, 0x6D, 0x1C, 0xEB, 0x51, 0xD6, 0x16, 0xB6, 0x48, 0x29, 0x77, 0x3E,
0xE7, 0x6D, 0x50, 0x75, 0x8D, 0x2A, 0xD5, 0x69, 0xF0, 0x35, 0xDF, 0x01, 0x9F, 0x2A, 0x54, 0x0B, 0xD5, 0x72, 0xAE, 0x03, 0x79, 0xDA, 0x1B, 0x8D, 0x47, 0x7A, 0xC0, 0x3D, 0x88, 0x56, 0x13, 0x63, 0x95, 0x42, 0xF9, 0x12, 0x51, 0xBF, 0x20, 0x5E, 0xE1, 0x95, 0xED, 0x0B, 0xB9, 0x24, 0xAF, 0x6F, 0x31, 0x91, 0x15, 0x0A, 0x87, 0xCF, 0x94, 0xC1, 0x66, 0x33, 0xF9, 0x5A, 0xA6, 0xE3, 0xBC, 0x5D,
0x17, 0xB1, 0x11, 0xD1, 0xA3, 0xFB, 0x3A, 0x9D, 0xB5, 0x22, 0x8D, 0xBD, 0x44, 0xFE, 0xB0, 0x86, 0x26, 0xF7, 0x3B, 0xC8, 0x8A, 0x2F, 0xBE, 0xEF, 0x08, 0xE1, 0x98, 0xF6, 0x06, 0xE9, 0x7B, 0xDA, 0x17, 0xC2, 0x5C, 0x7C, 0xA6, 0xEA, 0x70, 0xD2, 0x48, 0x11, 0x7B, 0xCF, 0x6C, 0x3E, 0x84, 0x1A, 0xA4, 0xFE, 0x76, 0xDB, 0xAA, 0x34, 0x4A, 0x02, 0xAD, 0x8D, 0xC6, 0x6F, 0x05, 0x85, 0x12, 0x95,
0xC9, 0x82, 0xEA, 0x43, 0x1F, 0x56, 0x7B, 0x0F, 0x50, 0xD7, 0x70, 0x5C, 0xCE, 0x78, 0x3A, 0xDE, 0x4A, 0x93, 0x13, 0xE2, 0x5E, 0xA8, 0x51, 0x70, 0xAF, 0x5A, 0x22, 0x6B, 0xAD, 0x34, 0xBF, 0x47, 0xA5, 0x87, 0x09, 0xE3, 0x36, 0x0E, 0x9B, 0x2C, 0x89, 0xC2, 0x17, 0x52, 0x9E, 0xDB, 0xBD, 0xEA, 0x60, 0x3C, 0xC1, 0x50, 0x23, 0x65, 0xF4, 0xDD, 0x7A, 0x11, 0x41, 0x98, 0xDA, 0x36, 0xFD, 0x4E,
0x3E, 0x00, 0x99, 0x67, 0xC1, 0xAA, 0xE5, 0xC9, 0x94, 0x3C, 0xF5, 0x0F, 0x18, 0x9A, 0x06, 0xC4, 0x62, 0xB8, 0x6F, 0x9F, 0x0D, 0xFD, 0x1F, 0xCD, 0x2C, 0x87, 0xDA, 0x49, 0xCB, 0x92, 0x24, 0x6A, 0xF1, 0x39, 0xD2, 0xB4, 0x8F, 0x66, 0xF1, 0xBB, 0x58, 0xF5, 0xA7, 0xE2, 0x31, 0x08, 0x54, 0x2A, 0xD3, 0x00, 0x89, 0xEB, 0xB4, 0x7D, 0x98, 0x15, 0x58, 0xD0, 0xF2, 0x24, 0x53, 0xAF, 0x69, 0xA3,
0x77, 0xD9, 0x28, 0xF6, 0x84, 0x07, 0x33, 0x6C, 0x1F, 0xA9, 0x7F, 0xE2, 0xAE, 0x56, 0xEC, 0x7F, 0x1D, 0xE8, 0x2A, 0x4E, 0xC1, 0x82, 0x42, 0x96, 0xE7, 0x3E, 0xA4, 0x19, 0x81, 0x58, 0xE4, 0xB2, 0x1E, 0x5A, 0x73, 0x29, 0x4F, 0xD8, 0x40, 0x04, 0x73, 0x25, 0x40, 0x8C, 0x77, 0xF8, 0x96, 0x74, 0xB2, 0x9C, 0x68, 0x32, 0x0C, 0xCC, 0x40, 0xB9, 0x30, 0xA1, 0x65, 0xB9, 0x7F, 0xCD, 0x20, 0xE3,
0xBB, 0x5D, 0xAD, 0x38, 0x50, 0xDC, 0x8F, 0xF4, 0x5B, 0xBF, 0x2B, 0x4B, 0x6A, 0xD6, 0x2F, 0x97, 0x3D, 0xA5, 0xD5, 0x78, 0x35, 0xDC, 0x68, 0xB9, 0x01, 0x73, 0xC6, 0xFB, 0x2D, 0xD5, 0x07, 0x7E, 0x9A, 0xCA, 0xFE, 0xA3, 0x12, 0xC4, 0x7E, 0xB2, 0x98, 0xD8, 0x67, 0xB7, 0x11, 0x5C, 0xCE, 0x45, 0x17, 0xF6, 0x4A, 0xDC, 0xA3, 0x59, 0xFC, 0x70, 0xE5, 0x88, 0x0D, 0x3B, 0xE9, 0x0A, 0x91, 0x2E,
0x43, 0x1C, 0x8A, 0xCD, 0x79, 0x24, 0xB4, 0x44, 0xD3, 0x03, 0x9C, 0xC6, 0x8E, 0x23, 0xB6, 0x6C, 0xFB, 0x56, 0x89, 0x05, 0xF1, 0xA6, 0x1C, 0x50, 0xF0, 0x13, 0x54, 0x98, 0x69, 0xA8, 0x4C, 0xEC, 0x30, 0x46, 0x02, 0x83, 0xE1, 0x63, 0x26, 0xF9, 0x3A, 0xC6, 0x1D, 0xE9, 0xA3, 0xC0, 0x35, 0xE4, 0x82, 0x10, 0xBC, 0x76, 0x13, 0x86, 0x19, 0x06, 0xAC, 0x45, 0xD6, 0x75, 0x9B, 0x4B, 0x65, 0xF7,
0xD2, 0xA0, 0xF2, 0x0A, 0xBC, 0x67, 0xA0, 0x14, 0x88, 0x74, 0xF9, 0x3E, 0x0C, 0xF0, 0x45, 0xCB, 0x11, 0x18, 0xC5, 0xB3, 0x5E, 0x27, 0x8F, 0xCD, 0xAA, 0x88, 0xB6, 0x20, 0x3C, 0xC0, 0x11, 0x6F, 0xC3, 0x91, 0xB5, 0x53, 0x37, 0xAB, 0x94, 0x54, 0x0C, 0x82, 0x5A, 0x44, 0x29, 0x85, 0x04, 0xA0, 0x64, 0xD1, 0x92, 0x37, 0xED, 0xC0, 0xD4, 0x93, 0x55, 0xC4, 0x20, 0xFA, 0x2A, 0xC7, 0xAD, 0x84,
0x54, 0x72, 0x34, 0x5E, 0xE0, 0x3B, 0xF0, 0x55, 0xE1, 0x33, 0x5E, 0xD8, 0x82, 0xA4, 0x5B, 0x8B, 0xAC, 0xE0, 0x6F, 0x3F, 0x98, 0xD7, 0x7A, 0x36, 0x6B, 0x43, 0xD6, 0xE6, 0x78, 0xF2, 0x89, 0xDC, 0x5D, 0x1B, 0xE6, 0x72, 0xF4, 0x1A, 0xD9, 0x70, 0xEA, 0xAE, 0xDC, 0x94, 0xFB, 0x6B, 0xC7, 0x49, 0xF2, 0x2B, 0x55, 0xAD, 0x6A, 0x41, 0x61, 0x2D, 0xF0, 0x80, 0x67, 0xA9, 0x59, 0xE1, 0x16, 0x05,
0xEC, 0x24, 0xB2, 0x93, 0x1E, 0x83, 0xC3, 0x09, 0x97, 0xB6, 0x1A, 0xAD, 0x6F, 0x20, 0xE1, 0x00, 0x78, 0x4F, 0x2B, 0xF8, 0x10, 0x4A, 0xE6, 0x06, 0xFA, 0x28, 0x63, 0x09, 0x95, 0x2E, 0x52, 0x22, 0xAE, 0x3F, 0xD0, 0x29, 0x8E, 0xBA, 0x46, 0x21, 0x9D, 0x2F, 0x67, 0x0A, 0xB5, 0x51, 0xE0, 0x1F, 0x7B, 0xB6, 0x0C, 0xE0, 0x24, 0x99, 0xB5, 0xDB, 0x19, 0x39, 0xBC, 0x07, 0x8F, 0x3C, 0x7C, 0xBA,
0x8B, 0x41, 0xCB, 0xEA, 0x4B, 0xAC, 0x2F, 0x71, 0xCC, 0x48, 0xEB, 0x2B, 0xCA, 0x42, 0xB3, 0x31, 0xED, 0x9C, 0xCF, 0x80, 0xA9, 0xC4, 0x5B, 0xB4, 0x9D, 0x83, 0xC9, 0xAC, 0x47, 0xB8, 0xCF, 0xA0, 0xFB, 0x83, 0x66, 0xA5, 0x07, 0x5F, 0xCE, 0x85, 0xC1, 0x4A, 0xD1, 0x23, 0x7E, 0x32, 0x96, 0xAA, 0x3C, 0xCC, 0x8C, 0x73, 0xF3, 0x04, 0x7A, 0x4D, 0xA1, 0x8B, 0xE4, 0x4C, 0xF0, 0xA2, 0x69, 0xD9,
0x0F, 0xA5, 0x77, 0x03, 0x6B, 0xDD, 0x59, 0xFC, 0x20, 0x8E, 0x67, 0x9D, 0x54, 0xFB, 0x8F, 0x5E, 0xC1, 0x44, 0x0A, 0x63, 0x15, 0x8E, 0x2C, 0x76, 0x1A, 0xDE, 0x54, 0x1E, 0xF7, 0x66, 0x00, 0x74, 0x36, 0x0D, 0xC5, 0x4A, 0xED, 0x79, 0x39, 0xFC, 0x01, 0x77, 0xEC, 0xA1, 0xC9, 0xF4, 0x13, 0x66, 0xDC, 0x5A, 0x33, 0xA3, 0x48, 0xC5, 0x28, 0xFA, 0x63, 0xD0, 0x26, 0x6E, 0xC3, 0x1E, 0x30, 0x4F,
0xC6, 0x60, 0xF3, 0x2A, 0xBC, 0x90, 0x1A, 0xA5, 0x79, 0x35, 0xD6, 0x08, 0x7F, 0x12, 0xDA, 0x73, 0x1C, 0x88, 0xB1, 0xDC, 0xEF, 0x3B, 0xD3, 0xF3, 0x47, 0xBA, 0x35, 0x78, 0x8C, 0x29, 0xE6, 0x93, 0x59, 0xE9, 0x96, 0x2F, 0xD3, 0xAD, 0x25, 0x9A, 0x5C, 0xB0, 0x31, 0x54, 0x6C, 0x42, 0x8D, 0xC0, 0x01, 0xFE, 0x20, 0xBC, 0x5F, 0xD8, 0x88, 0xAA, 0x3F, 0x0B, 0xB4, 0x37, 0x84, 0xD5, 0xAD, 0xFE,
0x22, 0x39, 0x89, 0xA1, 0x45, 0xCF, 0x33, 0xE7, 0xBB, 0x57, 0xF5, 0xA9, 0xC2, 0x39, 0xA1, 0x27, 0xF1, 0xCB, 0x33, 0x51, 0x74, 0x9E, 0x5F, 0x22, 0x99, 0x6C, 0xE9, 0xA7, 0xD9, 0xC7, 0x3E, 0xAE, 0xD6, 0x20, 0xB6, 0x6C, 0x1C, 0x86, 0x4D, 0xDA, 0x12, 0xE4, 0x90, 0x1C, 0xB8, 0x0B, 0xE6, 0x4B, 0x72, 0x9B, 0x82, 0xEA, 0x19, 0x38, 0x6E, 0x13, 0xEA, 0x7C, 0x9D, 0xF6, 0x59, 0x01, 0x66, 0x94,
0xB5, 0xDB, 0x52, 0xE5, 0x0B, 0x61, 0x84, 0x49, 0x02, 0x96, 0x14, 0x46, 0x6D, 0xD4, 0x5A, 0xB7, 0x48, 0x67, 0xA5, 0x20, 0xC2, 0x09, 0xB2, 0x7F, 0xCE, 0x05, 0x17, 0x5A, 0x0C, 0x4C, 0x6E, 0x0F, 0x7E, 0x50, 0x89, 0xF7, 0x5B, 0xE2, 0xBF, 0x6F, 0xB7, 0x41, 0x7C, 0xF0, 0x9C, 0xD3, 0x80, 0xAF, 0x2B, 0xD2, 0x3C, 0x69, 0xB3, 0x93, 0xE2, 0xBB, 0x57, 0xC8, 0x46, 0x1D, 0x90, 0xE9, 0x43, 0x7C,
0x0C, 0x70, 0x1E, 0xBF, 0x78, 0xF5, 0xB3, 0xDB, 0x68, 0xC5, 0x85, 0xE2, 0x2B, 0x91, 0x0F, 0xE4, 0x81, 0x06, 0xFE, 0x90, 0xE2, 0x42, 0xEC, 0x31, 0x51, 0xFC, 0x90, 0xC1, 0x7C, 0x9F, 0xBB, 0xEE, 0x2B, 0xC3, 0x3B, 0x10, 0x9F, 0x37, 0x05, 0x92, 0x2C, 0x62, 0xCE, 0x28, 0x5A, 0x39, 0x1F, 0x61, 0xEE, 0x12, 0xA8, 0x50, 0xD5, 0x2B, 0x4B, 0x09, 0x90, 0x27, 0xDF, 0x75, 0xBF, 0xA5, 0x2C, 0xD2,
0x99, 0xF9, 0xA8, 0x37, 0x93, 0x28, 0x1A, 0x9E, 0x2F, 0xEE, 0x24, 0x5B, 0xB3, 0xF0, 0x74, 0x3D, 0x9C, 0xD3, 0x59, 0x7A, 0x2A, 0x69, 0x8A, 0xC5, 0xA3, 0x74, 0x45, 0xE2, 0x24, 0xF8, 0x36, 0x5F, 0x94, 0xE4, 0xA6, 0xCC, 0x7B, 0xEC, 0x49, 0xD1, 0xF9, 0xA5, 0x08, 0xBC, 0x76, 0xFB, 0x96, 0xC3, 0x41, 0x8B, 0xC7, 0x05, 0x7D, 0xF1, 0xA7, 0x74, 0xFC, 0x64, 0xAF, 0x32, 0x52, 0x1A, 0xE3, 0x58,
0x3F, 0x27, 0x63, 0xD8, 0x55, 0xCA, 0x43, 0x81, 0x51, 0xAC, 0x77, 0xCE, 0x09, 0x4A, 0xA8, 0x26, 0xC3, 0x1D, 0x36, 0xBD, 0xAA, 0xD9, 0x57, 0x19, 0x10, 0xD8, 0x2E, 0xAE, 0x63, 0x8A, 0x1B, 0xDA, 0x74, 0x03, 0x4B, 0x65, 0x27, 0xAE, 0x69, 0x82, 0x1D, 0x4F, 0x8A, 0xDE, 0x48, 0xB1, 0x04, 0xDF, 0x78, 0x56, 0xF8, 0x18, 0x9D, 0x60, 0x20, 0xCF, 0x3E, 0xA0, 0x05, 0xCB, 0xF6, 0x67, 0x85, 0xC6,
0x78, 0xCF, 0x87, 0x02, 0xEC, 0xA4, 0x69, 0xFD, 0xD5, 0x11, 0x3C, 0x9F, 0x87, 0xD9, 0x64, 0xF8, 0x8A, 0x6C, 0xE7, 0x4E, 0x01, 0x15, 0x9C, 0xF3, 0xB7, 0x66, 0x97, 0x02, 0xD2, 0x40, 0xB8, 0x4F, 0xAC, 0x16, 0xFF, 0x8D, 0xDD, 0x0E, 0xC5, 0x33, 0xB4, 0xE8, 0x69, 0x1A, 0x32, 0x8C, 0x6A, 0x2D, 0xA2, 0x10, 0xB3, 0x6D, 0xE5, 0x32, 0xB8, 0x85, 0x14, 0xE6, 0x4D, 0x7B, 0x96, 0x3A, 0xB7, 0x07,
0x52, 0xE7, 0xB9, 0x4A, 0x74, 0x21, 0xBD, 0x0B, 0x8F, 0xBA, 0x6A, 0xF4, 0x21, 0x36, 0xBD, 0x05, 0x46, 0xD7, 0xA1, 0x81, 0xEF, 0xCA, 0x73, 0x47, 0x84, 0x38, 0xF6, 0x55, 0x7A, 0xEB, 0x9B, 0x29, 0xCF, 0x7C, 0xBA, 0x33, 0x55, 0x99, 0xF5, 0x5E, 0x94, 0x25, 0xC7, 0xA2, 0xEC, 0xD3, 0x53, 0xBB, 0xEB, 0x3B, 0xD1, 0x93, 0x44, 0xCA, 0x53, 0xEF, 0x6A, 0x8E, 0xBD, 0x10, 0xDC, 0x22, 0xED, 0xAA,
0x99, 0x0F, 0x17, 0xAB, 0x90, 0x33, 0xE0, 0x3F, 0x5F, 0x29, 0xDD, 0x4D, 0xC4, 0x7D, 0x99, 0x58, 0xB3, 0x2E, 0x0E, 0x62, 0x3D, 0x90, 0x2E, 0xE1, 0x09, 0xCE, 0xB3, 0x25, 0xC8, 0x0E, 0x6F, 0x58, 0xF1, 0x42, 0x9E, 0xD4, 0x1F, 0x76, 0x40, 0x06, 0xD2, 0x7C, 0x3A, 0x5A, 0x79, 0x21, 0x99, 0x0B, 0x66, 0x86, 0x26, 0x5D, 0x1C, 0x82, 0x00, 0xAA, 0x37, 0xD9, 0x29, 0x5D, 0xA3, 0x49, 0x71, 0x30,
0xF8, 0x80, 0x40, 0xC8, 0xF6, 0x57, 0x82, 0xA9, 0xCD, 0x7B, 0x9A, 0x00, 0x65, 0xE5, 0x18, 0xEE, 0x77, 0x94, 0xF7, 0xBB, 0xA7, 0x1E, 0xBF, 0x5A, 0xA5, 0x71, 0x49, 0x86, 0xA1, 0x3A, 0xC0, 0x90, 0x0A, 0x6A, 0x13, 0x61, 0xEE, 0xBF, 0xA4, 0xE1, 0x4A, 0xB0, 0xFD, 0x00, 0xC0, 0x42, 0xF7, 0xC7, 0x47, 0xDB, 0xA4, 0xFB, 0xB6, 0xDA, 0x96, 0x62, 0x11, 0xB1, 0x76, 0xFA, 0xC9, 0x83, 0xD7, 0x63,
0xC1, 0x55, 0xE4, 0x68, 0x1C, 0x9F, 0x05, 0xEF, 0x4B, 0x1B, 0xF8, 0xB1, 0x3B, 0xA7, 0x0D, 0xCD, 0x3F, 0xC6, 0x22, 0x50, 0xE0, 0x6B, 0xFC, 0x8E, 0x23, 0xEF, 0x1A, 0xDB, 0x5E, 0xFC, 0x22, 0xD7, 0xA7, 0xE3, 0x8C, 0xB1, 0x37, 0x84, 0x2A, 0x68, 0x8D, 0x14, 0x6C, 0xD6, 0xA8, 0x85, 0x2F, 0x6F, 0xAF, 0x19, 0x79, 0x4B, 0x2E, 0x6E, 0x40, 0xF3, 0xCB, 0x4B, 0x95, 0x3A, 0x15, 0x03, 0xB0, 0x1F,
0x2D, 0xA6, 0x8C, 0x26, 0xD9, 0xC2, 0x72, 0x25, 0xB7, 0x8D, 0x31, 0x71, 0xD0, 0x52, 0x85, 0x68, 0x13, 0x5F, 0x89, 0x33, 0x7D, 0x06, 0x48, 0x35, 0xC3, 0x63, 0x95, 0x30, 0xB0, 0x76, 0x4C, 0x80, 0x2D, 0x3E, 0xC3, 0x04, 0x52, 0xE6, 0x0E, 0xC9, 0xEF, 0x2F, 0x9A, 0x4C, 0x13, 0x5E, 0xE0, 0x91, 0x14, 0xF0, 0x06, 0xCD, 0x9C, 0xE4, 0x23, 0xBA, 0x86, 0x18, 0xE8, 0x57, 0xBF, 0x8E, 0x46, 0xDE,
0x72, 0x06, 0xBC, 0x3B, 0x7D, 0x4E, 0x34, 0xDF, 0x66, 0xD4, 0x58, 0xEA, 0x26, 0x95, 0xFD, 0xB2, 0xDA, 0x9F, 0xE8, 0xCD, 0xB4, 0x9C, 0xD4, 0x7A, 0xAC, 0xD0, 0x45, 0xEB, 0x11, 0xCC, 0x05, 0xB7, 0xF2, 0x59, 0x7A, 0xFB, 0x9A, 0x6F, 0xB3, 0x57, 0x40, 0xBC, 0xDC, 0x81, 0xF3, 0xB4, 0x08, 0xCB, 0x52, 0x6A, 0xBD, 0x8C, 0x5F, 0x0C, 0xA5, 0x53, 0x70, 0x0A, 0xD1, 0xA8, 0x74, 0xF3, 0x60, 0x97,
0x40, 0xD3, 0xFE, 0x5F, 0xAF, 0xF2, 0x89, 0x9D, 0x41, 0xA6, 0x07, 0x81, 0xBB, 0x1B, 0x44, 0x2D, 0x7F, 0x03, 0x48, 0x16, 0x5D, 0x11, 0xE6, 0x56, 0x18, 0x02, 0x73, 0xA0, 0x59, 0x8B, 0xE2, 0x67, 0x99, 0x19, 0xCA, 0x25, 0x44, 0xD8, 0x1D, 0xA0, 0x88, 0x08, 0x62, 0x21, 0x35, 0x97, 0x45, 0x7B, 0xE5, 0xA6, 0x27, 0x3F, 0xF5, 0x7D, 0xD5, 0x2A, 0xFD, 0xA0, 0x64, 0x30, 0x21, 0xCC, 0x0D, 0xE9,
0x81, 0x54, 0x12, 0x99, 0x18, 0x07, 0xCD, 0x11, 0xE5, 0x16, 0xC7, 0x49, 0xA1, 0xDC, 0x75, 0xC4, 0x57, 0xF2, 0xA8, 0x74, 0xF8, 0x8C, 0x32, 0xA1, 0xF6, 0x88, 0xD8, 0x25, 0xC2, 0x36, 0xA9, 0x45, 0x28, 0xDD, 0xA9, 0x5F, 0xBD, 0x82, 0x34, 0xE2, 0x73, 0xF9, 0xAD, 0xCF, 0x72, 0xEA, 0xBC, 0x23, 0x39, 0x95, 0xD1, 0x55, 0x1C, 0xAD, 0x38, 0x92, 0xC4, 0x43, 0xDF, 0x8B, 0x50, 0xAD, 0x34, 0xA2,
0x23, 0xB3, 0x8A, 0xE4, 0x6D, 0xBB, 0x53, 0x79, 0x61, 0x86, 0xF4, 0x6C, 0x36, 0x5D, 0xED, 0x0E, 0x93, 0x36, 0xBE, 0x28, 0xC7, 0x42, 0x6E, 0xBB, 0x0F, 0x66, 0xB6, 0x4C, 0xF9, 0x7D, 0x1C, 0xEF, 0x89, 0x71, 0x3C, 0x8E, 0x0A, 0xF5, 0x64, 0xC6, 0x25, 0x4F, 0x3B, 0x9E, 0x53, 0x19, 0x60, 0x8A, 0xFD, 0x02, 0x66, 0xE1, 0xBF, 0x6E, 0xEA, 0x5D, 0x14, 0x78, 0x02, 0xB9, 0xF8, 0x7E, 0x5C, 0xC4,
0x6B, 0xCC, 0x31, 0x45, 0xD8, 0xA4, 0x3C, 0xFB, 0xC1, 0x31, 0xB0, 0x24, 0xD0, 0x91, 0x18, 0xAD, 0xD5, 0x66, 0x84, 0x53, 0x96, 0xDF, 0x23, 0xCE, 0x49, 0xE5, 0x34, 0x94, 0x07, 0x65, 0xD4, 0x53, 0xB2, 0x00, 0xEC, 0xD5, 0x2B, 0xAB, 0x46, 0x0E, 0xB5, 0x8E, 0xE6, 0x05, 0x85, 0xC5, 0xDA, 0xAA, 0x4A, 0xB7, 0x7A, 0x30, 0x9A, 0x49, 0x0B, 0x87, 0xB2, 0xEF, 0x97, 0x18, 0x3E, 0xD7, 0x0F, 0xEE,
0x49, 0x00, 0xF7, 0x61, 0x1E, 0x81, 0x27, 0x92, 0x1B, 0x4B, 0x97, 0xDE, 0x02, 0xB9, 0x81, 0x48, 0x15, 0xE6, 0x0A, 0xEE, 0x16, 0xAB, 0x7E, 0x5C, 0x9B, 0x1B, 0xAC, 0x72, 0xDF, 0xBC, 0x98, 0x2E, 0xC9, 0x63, 0x9E, 0x4F, 0x70, 0x92, 0xE8, 0x7E, 0xD9, 0x67, 0x2E, 0xB1, 0xF7, 0x22, 0x34, 0x72, 0x0F, 0xD4, 0x16, 0x89, 0xF9, 0x24, 0xC8, 0xE1, 0x4E, 0x35, 0x62, 0xCE, 0x6F, 0x9F, 0x2C, 0x94,
0xD9, 0xA9, 0x7A, 0x95, 0xC8, 0xEB, 0x5C, 0xD4, 0xAB, 0xE6, 0x5E, 0x77, 0x3E, 0x55, 0xFB, 0x6F, 0xA0, 0xC5, 0x41, 0xB4, 0x68, 0x00, 0xFD, 0x38, 0xD5, 0x83, 0xF5, 0x21, 0x56, 0x3C, 0x0E, 0x82, 0xFB, 0x22, 0x37, 0xC4, 0x19, 0xB8, 0x59, 0x37, 0xA2, 0x1D, 0xC9, 0x57, 0x7A, 0x44, 0x9A, 0xF1, 0x54, 0xA0, 0xE9, 0x44, 0xB4, 0x5B, 0xA0, 0x71, 0x27, 0xC2, 0xA6, 0x11, 0xE8, 0x51, 0xBE, 0x77,
0x20, 0x57, 0xC0, 0x10, 0x36, 0xA7, 0x04, 0x71, 0x2D, 0x82, 0x11, 0xF3, 0xA3, 0xC6, 0x28, 0xE0, 0x33, 0x5D, 0x98, 0x7B, 0xD9, 0x4D, 0x90, 0xB5, 0x28, 0x67, 0x45, 0xC8, 0x86, 0xAE, 0xE7, 0x44, 0xA6, 0x75, 0xDE, 0x86, 0xF6, 0x26, 0xD3, 0x03, 0xFE, 0x75, 0x3F, 0xDE, 0xA6, 0x64, 0xBD, 0x1C, 0x80, 0xC2, 0x63, 0x07, 0x76, 0xD8, 0x3B, 0x04, 0x91, 0xFA, 0x78, 0x44, 0x88, 0x06, 0xFC, 0x38
};

View File

@@ -38,6 +38,8 @@
#define DEFAULT_META_FLAGS 0x00
#define DEFAULT_META_POS 0 // 0=bottom
#define DEFAULT_LED_BRIGHTNESS 50
#define DEFAULT_PIPELINE_MODE 0 // 0=dynamic, 1=balanced, 2=none
#define DEFAULT_DITHER_NOISE 0 // 1=error scatter ON, 0=OFF (driver dither fix makes this unnecessary)
#define DEFAULT_IMMICH_URL "https://photos.example.com"
// --- Timing ---
@@ -62,23 +64,23 @@
#define PORTRAIT_GAP_PX 8
#define PORTRAIT_WIDTH 267 // (DISPLAY_HEIGHT * 2) / 3
// --- Dither Palette (Spectra 6) ---
// Initial values — calibrate on real hardware
#define PALETTE_BLACK_R 0
#define PALETTE_BLACK_G 0
#define PALETTE_BLACK_B 0
#define PALETTE_WHITE_R 255
#define PALETTE_WHITE_G 255
#define PALETTE_WHITE_B 255
#define PALETTE_RED_R 200
#define PALETTE_RED_G 30
#define PALETTE_RED_B 30
#define PALETTE_GREEN_R 30
#define PALETTE_GREEN_G 160
#define PALETTE_GREEN_B 30
#define PALETTE_BLUE_R 30
#define PALETTE_BLUE_G 30
#define PALETTE_BLUE_B 200
#define PALETTE_YELLOW_R 220
#define PALETTE_YELLOW_G 200
#define PALETTE_YELLOW_B 30
// --- Dither Palette (Spectra 6 calibrated, from epdoptimize "spectra6") ---
// Measured display appearance — what the panel actually shows
#define PALETTE_BLACK_R 0x1F
#define PALETTE_BLACK_G 0x22
#define PALETTE_BLACK_B 0x26
#define PALETTE_WHITE_R 0xB9
#define PALETTE_WHITE_G 0xC7
#define PALETTE_WHITE_B 0xC9
#define PALETTE_RED_R 0x62
#define PALETTE_RED_G 0x20
#define PALETTE_RED_B 0x1E
#define PALETTE_GREEN_R 0x35
#define PALETTE_GREEN_G 0x56
#define PALETTE_GREEN_B 0x3A
#define PALETTE_BLUE_R 0x23
#define PALETTE_BLUE_G 0x3F
#define PALETTE_BLUE_B 0x8E
#define PALETTE_YELLOW_R 0xC1
#define PALETTE_YELLOW_G 0xBB
#define PALETTE_YELLOW_B 0x1E

View File

@@ -22,7 +22,14 @@ void DisplayManager::begin(PowerManager& power) {
// Set rotation for landscape (device physically rotated)
_display->setRotation(3);
Serial.printf("[display] Initialized: %dx%d, rotation=%d\n",
// 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());
}
@@ -235,44 +242,114 @@ void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFla
}
}
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();
// Pick a corner opposite to metadata position
static constexpr uint16_t MARGIN = 6;
uint16_t tx, ty;
lgfx::textdatum_t datum;
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:
tx = DISPLAY_WIDTH - MARGIN;
ty = DISPLAY_HEIGHT - MARGIN;
datum = bottom_right;
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = DISPLAY_HEIGHT - badgeH - MARGIN;
break;
case MetaPosition::OverlayTopRight:
tx = MARGIN;
ty = DISPLAY_HEIGHT - MARGIN;
datum = bottom_left;
bx = MARGIN;
by = DISPLAY_HEIGHT - badgeH - MARGIN;
break;
case MetaPosition::OverlayBottomLeft:
tx = DISPLAY_WIDTH - MARGIN;
ty = MARGIN;
datum = top_right;
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = MARGIN;
break;
case MetaPosition::OverlayBottomRight:
case MetaPosition::CaptionBottom:
default:
tx = MARGIN;
ty = MARGIN;
datum = top_left;
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);
_display->setTextColor(TFT_BLACK);
_display->setTextDatum(datum);
_display->drawString("SLP", tx, ty);
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() {

View File

@@ -22,12 +22,14 @@ public:
uint16_t regionX, uint16_t regionY, uint16_t regionW, uint16_t regionH,
const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S");
void showSleepIndicator(MetaPosition metaPos);
void showBatteryIndicator(uint8_t percent, MetaPosition metaPos);
void refresh();
private:
PowerManager* _power = nullptr;
M5GFX* _display = nullptr;
void drawBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
void triggerRefresh();
uint16_t paletteToColor565(uint8_t index);
};

View File

@@ -1,4 +1,5 @@
#include "image_pipeline.h"
#include "blue_noise.h"
#include <M5GFX.h>
#include <esp_heap_caps.h>
#include <cstring>
@@ -15,6 +16,301 @@ static const uint8_t PALETTE_CALIBRATED[6][3] = {
{0xC1, 0xBB, 0x1E} // Yellow -> appears as olive/mustard
};
// ---------------------------------------------------------------------------
// Color science helpers (sRGB <-> LAB, luma, saturation)
// ---------------------------------------------------------------------------
static inline uint8_t clampByte(float v) {
if (v <= 0.0f) return 0;
if (v >= 255.0f) return 255;
return (uint8_t)(v + 0.5f);
}
static inline float clampF(float v, float lo, float hi) {
return (v < lo) ? lo : (v > hi) ? hi : v;
}
static inline float luma709(float r, float g, float b) {
return 0.2126f * r + 0.7152f * g + 0.0722f * b;
}
// Pre-computed sRGB-to-linear LUT (avoids per-pixel powf)
static const float* getSrgbToLinear() {
static float lut[256];
static bool initialized = false;
if (!initialized) {
for (int i = 0; i < 256; i++) {
float n = i / 255.0f;
lut[i] = (n > 0.04045f)
? powf((n + 0.055f) / 1.055f, 2.4f)
: n / 12.92f;
}
initialized = true;
}
return lut;
}
static inline float labForwardPivot(float v) {
return (v > 0.008856f) ? cbrtf(v) : 7.787f * v + 16.0f / 116.0f;
}
// Lightness-only conversion (for DRC histogram pass)
static float rgbToLabLightness(uint8_t r, uint8_t g, uint8_t b) {
const float* lut = getSrgbToLinear();
float y = lut[r] * 0.2126729f + lut[g] * 0.7151522f + lut[b] * 0.0721750f;
return 116.0f * labForwardPivot(y) - 16.0f;
}
// Full sRGB -> CIE LAB (D65 illuminant)
static void srgbToLab(uint8_t r, uint8_t g, uint8_t b,
float* L, float* a, float* bOut) {
const float* lut = getSrgbToLinear();
float rn = lut[r], gn = lut[g], bn = lut[b];
float x = rn * 0.4124564f + gn * 0.3575761f + bn * 0.1804375f;
float y = rn * 0.2126729f + gn * 0.7151522f + bn * 0.0721750f;
float z = rn * 0.0193339f + gn * 0.1191920f + bn * 0.9503041f;
float fx = labForwardPivot(x / 0.95047f);
float fy = labForwardPivot(y);
float fz = labForwardPivot(z / 1.08883f);
*L = 116.0f * fy - 16.0f;
*a = 500.0f * (fx - fy);
*bOut = 200.0f * (fy - fz);
}
// CIE LAB -> sRGB (D65 illuminant)
static void labToSrgb(float L, float a, float b,
uint8_t* rOut, uint8_t* gOut, uint8_t* bOut) {
float fy = (L + 16.0f) / 116.0f;
float fx = a / 500.0f + fy;
float fz = fy - b / 200.0f;
float x = (fx > 0.206897f) ? fx * fx * fx : (fx - 16.0f / 116.0f) / 7.787f;
float y = (fy > 0.206897f) ? fy * fy * fy : (fy - 16.0f / 116.0f) / 7.787f;
float z = (fz > 0.206897f) ? fz * fz * fz : (fz - 16.0f / 116.0f) / 7.787f;
x *= 0.95047f;
z *= 1.08883f;
float rl = x * 3.2404542f + y * -1.5371385f + z * -0.4985314f;
float gl = x * -0.9692660f + y * 1.8760108f + z * 0.0415560f;
float bl = x * 0.0556434f + y * -0.2040259f + z * 1.0572252f;
auto linearToSrgb = [](float v) -> float {
if (v <= 0.0f) return 0.0f;
return (v > 0.0031308f)
? 1.055f * powf(v, 1.0f / 2.4f) - 0.055f
: 12.92f * v;
};
*rOut = clampByte(linearToSrgb(rl) * 255.0f);
*gOut = clampByte(linearToSrgb(gl) * 255.0f);
*bOut = clampByte(linearToSrgb(bl) * 255.0f);
}
// HSV-style saturation (max-min)/max in 0..1 range
static inline float pixelSaturation(float r, float g, float b) {
float mx = fmaxf(r, fmaxf(g, b));
float mn = fminf(r, fminf(g, b));
return (mx > 0.0f) ? (mx - mn) / mx : 0.0f;
}
// ---------------------------------------------------------------------------
// Tone mapping (replaces enhanceContrast)
//
// Uses epdoptimize's "dynamic" preset:
// - Asymmetric power-curve S-curve built into a 256-entry LUT
// - HSL-space saturation adjustment (preserves hue fidelity)
// - Lookup-table contrast and exposure
// ---------------------------------------------------------------------------
static constexpr float SHADOW_TONE_RESPONSE = 1.5f;
static void toneMap(uint8_t* rgb, size_t pixelCount) {
static constexpr float EXPOSURE = 0.0f; // stops (2^0 = 1.0x)
static constexpr float SATURATION_ADJ = 0.3f; // -> 1.3x multiplier
static constexpr float CONTRAST_ADJ = 0.0f; // -> 1.0x multiplier
static constexpr float STRENGTH = 0.9f;
static constexpr float SHADOW_BOOST = 0.0f;
static constexpr float HIGHLIGHT_COMP = -1.5f;
static constexpr float MIDPOINT = 0.5f;
float exposureMul = powf(2.0f, EXPOSURE);
float satMul = fmaxf(0.0f, SATURATION_ADJ + 1.0f);
float contrastMul = (CONTRAST_ADJ < 0.0f)
? fmaxf(0.5f, 1.0f + CONTRAST_ADJ * 0.5f)
: CONTRAST_ADJ + 1.0f;
// Build S-curve LUT (asymmetric power curve per epdoptimize)
float mid = clampF(MIDPOINT, 0.01f, 0.99f);
float shadowExp = clampF(1.0f - STRENGTH * SHADOW_BOOST * SHADOW_TONE_RESPONSE, 0.15f, 3.0f);
float highlightExp = clampF(1.0f - STRENGTH * HIGHLIGHT_COMP, 0.15f, 3.0f);
uint8_t exposureLut[256];
uint8_t toneLut[256];
for (int v = 0; v < 256; v++) {
exposureLut[v] = clampByte((float)v * exposureMul);
float tv = clampF(((float)v - 128.0f) * contrastMul + 128.0f, 0.0f, 255.0f);
if (STRENGTH != 0.0f) {
float n = tv / 255.0f;
float curved;
if (n <= mid) {
curved = powf(n / mid, shadowExp) * mid;
} else {
curved = mid + powf((n - mid) / (1.0f - mid), highlightExp) * (1.0f - mid);
}
tv = clampF(curved * 255.0f, 0.0f, 255.0f);
}
toneLut[v] = (uint8_t)tv;
}
bool needsSaturation = (satMul != 1.0f);
for (size_t i = 0; i < pixelCount; i++) {
size_t idx = i * 3;
if (!needsSaturation) {
rgb[idx] = toneLut[exposureLut[rgb[idx]]];
rgb[idx + 1] = toneLut[exposureLut[rgb[idx + 1]]];
rgb[idx + 2] = toneLut[exposureLut[rgb[idx + 2]]];
continue;
}
// HSL-space saturation (preserves hue, matches epdoptimize)
float r0 = exposureLut[rgb[idx]] / 255.0f;
float g0 = exposureLut[rgb[idx + 1]] / 255.0f;
float b0 = exposureLut[rgb[idx + 2]] / 255.0f;
float maxC = fmaxf(r0, fmaxf(g0, b0));
float minC = fminf(r0, fminf(g0, b0));
float light = (maxC + minC) * 0.5f;
float r = r0, g = g0, b = b0;
if (maxC != minC) {
float delta = maxC - minC;
float sat = (light > 0.5f)
? delta / (2.0f - maxC - minC)
: delta / fmaxf(maxC + minC, 1e-6f);
float hue;
if (maxC == r0) {
hue = (g0 - b0) / delta;
if (g0 < b0) hue += 6.0f;
hue /= 6.0f;
} else if (maxC == g0) {
hue = ((b0 - r0) / delta + 2.0f) / 6.0f;
} else {
hue = ((r0 - g0) / delta + 4.0f) / 6.0f;
}
float newSat = clampF(sat * satMul, 0.0f, 1.0f);
float c = (1.0f - fabsf(2.0f * light - 1.0f)) * newSat;
float x = c * (1.0f - fabsf(fmodf(hue * 6.0f, 2.0f) - 1.0f));
float m = light - c * 0.5f;
int sector = (int)(hue * 6.0f);
if (sector >= 6) sector = 5;
switch (sector) {
case 0: r = c + m; g = x + m; b = m; break;
case 1: r = x + m; g = c + m; b = m; break;
case 2: r = m; g = c + m; b = x + m; break;
case 3: r = m; g = x + m; b = c + m; break;
case 4: r = x + m; g = m; b = c + m; break;
case 5: r = c + m; g = m; b = x + m; break;
}
}
rgb[idx] = toneLut[clampByte(r * 255.0f)];
rgb[idx + 1] = toneLut[clampByte(g * 255.0f)];
rgb[idx + 2] = toneLut[clampByte(b * 255.0f)];
}
Serial.printf("[pipeline] toneMap: exposure=%.1f sat=%.1fx strength=%.1f\n",
EXPOSURE, satMul, STRENGTH);
}
// ---------------------------------------------------------------------------
// Dynamic range compression (fast luma path with chroma protection)
//
// Uses epdoptimize's "balanced" preset approach:
// - Histogram percentile scan for source range detection
// - Remap into palette luminance range
// - smoothstep chroma protection prevents saturated color washout
// ---------------------------------------------------------------------------
static void compressDynamicRange(uint8_t* rgb, size_t pixelCount) {
static constexpr float STRENGTH = 1.0f;
static constexpr float LOW_PERCENTILE = 0.01f;
static constexpr float HIGH_PERCENTILE = 0.99f;
float blackY = luma709(PALETTE_CALIBRATED[0][0],
PALETTE_CALIBRATED[0][1],
PALETTE_CALIBRATED[0][2]);
float whiteY = luma709(PALETTE_CALIBRATED[1][0],
PALETTE_CALIBRATED[1][1],
PALETTE_CALIBRATED[1][2]);
float targetRange = whiteY - blackY;
if (targetRange <= 0.0f) return;
// Build luma histogram for percentile detection
uint32_t histogram[256] = {0};
for (size_t i = 0; i < pixelCount; i++) {
size_t idx = i * 3;
histogram[clampByte(luma709(rgb[idx], rgb[idx + 1], rgb[idx + 2]))]++;
}
// Find percentile endpoints
auto findPercentile = [&](float p) -> float {
uint32_t target = (uint32_t)((pixelCount - 1) * p);
uint32_t seen = 0;
for (int i = 0; i < 256; i++) {
seen += histogram[i];
if (seen > target) return (float)i;
}
return 255.0f;
};
float sourceBlackY = findPercentile(LOW_PERCENTILE);
float sourceWhiteY = findPercentile(HIGH_PERCENTILE);
float sourceRange = sourceWhiteY - sourceBlackY;
if (sourceRange <= 0.0001f) return;
for (size_t i = 0; i < pixelCount; i++) {
size_t idx = i * 3;
float r = rgb[idx], g = rgb[idx + 1], b = rgb[idx + 2];
float y = luma709(r, g, b);
float normalizedY = clampF((y - sourceBlackY) / sourceRange, 0.0f, 1.0f);
float targetY = blackY + normalizedY * targetRange;
// Chroma protection: smoothstep(0.18, 0.68, saturation) * 0.85
float sat = pixelSaturation(r, g, b);
float chromaProtection = 0.0f;
if (sat > 0.18f) {
float t = clampF((sat - 0.18f) / (0.68f - 0.18f), 0.0f, 1.0f);
chromaProtection = t * t * (3.0f - 2.0f * t) * 0.85f;
}
float effectiveStrength = STRENGTH * (1.0f - chromaProtection);
float nextY = y + (targetY - y) * effectiveStrength;
float ratio = (y > 0.0f) ? nextY / y : 0.0f;
float maxChannel = fmaxf(r, fmaxf(g, b));
if (maxChannel > 0.0f) ratio = fminf(ratio, 255.0f / maxChannel);
rgb[idx] = clampByte(r * ratio);
rgb[idx + 1] = clampByte(g * ratio);
rgb[idx + 2] = clampByte(b * ratio);
}
Serial.printf("[pipeline] DRC: src=[%.0f..%.0f] -> dst=[%.0f..%.0f]\n",
sourceBlackY, sourceWhiteY, blackY, whiteY);
}
// JPEGDEC draw callback: receives decoded MCU blocks and writes RGB888 to buffer
static int jpegDrawCallback(JPEGDRAW* pDraw) {
auto* ctx = static_cast<DecodeContext*>(pDraw->pUser);
@@ -43,33 +339,6 @@ static int jpegDrawCallback(JPEGDRAW* pDraw) {
return 1;
}
// Contrast/saturation enhancement applied before dithering
static void enhanceContrast(uint8_t* rgb, size_t pixelCount) {
static constexpr float CONTRAST = 1.25f;
static constexpr float SATURATION = 1.15f;
static constexpr float MID = 128.0f;
for (size_t i = 0; i < pixelCount; i++) {
size_t idx = i * 3;
float r = rgb[idx];
float g = rgb[idx + 1];
float b = rgb[idx + 2];
r = (r - MID) * CONTRAST + MID;
g = (g - MID) * CONTRAST + MID;
b = (b - MID) * CONTRAST + MID;
float lum = 0.299f * r + 0.587f * g + 0.114f * b;
r = lum + (r - lum) * SATURATION;
g = lum + (g - lum) * SATURATION;
b = lum + (b - lum) * SATURATION;
rgb[idx] = (uint8_t)fminf(fmaxf(r, 0.0f), 255.0f);
rgb[idx + 1] = (uint8_t)fminf(fmaxf(g, 0.0f), 255.0f);
rgb[idx + 2] = (uint8_t)fminf(fmaxf(b, 0.0f), 255.0f);
}
}
// Average a single edge of the fitted image (4 rows or columns deep)
static constexpr int EDGE_DEPTH = 4;
@@ -670,10 +939,19 @@ ProcessedImage ImagePipeline::process(uint8_t* jpegData, size_t jpegSize) {
Serial.printf("[pipeline] Got %dx%d fitted RGB\n", outW, outH);
// Enhance contrast/saturation for e-ink readability
enhanceContrast(rgb, (size_t)outW * outH);
size_t totalPixels = (size_t)outW * outH;
switch (_mode) {
case PipelineMode::DYNAMIC:
toneMap(rgb, totalPixels);
break;
case PipelineMode::BALANCED:
compressDynamicRange(rgb, totalPixels);
break;
case PipelineMode::NONE:
break;
// No default — compiler warns on unhandled PipelineMode via -Wswitch
}
// Row-by-row Floyd-Steinberg dithering to 6-color palette
uint8_t* dithered = ditherRowByRow(rgb, outW, outH);
free(rgb);
@@ -685,7 +963,11 @@ ProcessedImage ImagePipeline::process(uint8_t* jpegData, size_t jpegSize) {
result.width = outW;
result.height = outH;
result.valid = true;
Serial.printf("[pipeline] Processing complete (%dx%d)\n", outW, outH);
const char* modeName = (_mode == PipelineMode::DYNAMIC) ? "dynamic"
: (_mode == PipelineMode::BALANCED) ? "balanced"
: "none";
Serial.printf("[pipeline] Processing complete (%dx%d, mode=%s)\n",
outW, outH, modeName);
return result;
}
@@ -727,7 +1009,18 @@ ProcessedImage ImagePipeline::processPortraitPair(uint8_t* jpeg1Data, size_t jpe
free(rgb2);
}
enhanceContrast(combined, (size_t)DISPLAY_WIDTH * DISPLAY_HEIGHT);
size_t combinedPixels = (size_t)DISPLAY_WIDTH * DISPLAY_HEIGHT;
switch (_mode) {
case PipelineMode::DYNAMIC:
toneMap(combined, combinedPixels);
break;
case PipelineMode::BALANCED:
compressDynamicRange(combined, combinedPixels);
break;
case PipelineMode::NONE:
break;
// No default — compiler warns on unhandled PipelineMode via -Wswitch
}
uint8_t* dithered = ditherRowByRow(combined, DISPLAY_WIDTH, DISPLAY_HEIGHT);
free(combined);
@@ -792,8 +1085,14 @@ uint8_t* ImagePipeline::ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t he
memset(errNext, 0, rowBytes);
}
for (uint16_t x = 0; x < width; x++) {
size_t errIdx = x * 3;
// Serpentine: alternate scan direction each row
bool forward = (y % 2 == 0);
int xStart = forward ? 0 : (int)width - 1;
int xEnd = forward ? (int)width : -1;
int xStep = forward ? 1 : -1;
for (int x = xStart; x != xEnd; x += xStep) {
size_t errIdx = (size_t)x * 3;
int r = constrain(errCurrent[errIdx], 0, 255);
int g = constrain(errCurrent[errIdx + 1], 0, 255);
@@ -806,29 +1105,77 @@ uint8_t* ImagePipeline::ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t he
int errG = g - PALETTE_CALIBRATED[nearest][1];
int errB = b - PALETTE_CALIBRATED[nearest][2];
if (x + 1 < width) {
size_t ni = (x + 1) * 3;
// Floyd-Steinberg: mirror dx offsets on reverse rows
int xRight = forward ? x + 1 : x - 1;
int xLeft = forward ? x - 1 : x + 1;
// 7/16 to next pixel in scan direction
if (xRight >= 0 && xRight < (int)width) {
size_t ni = (size_t)xRight * 3;
errCurrent[ni] += errR * 7 / 16;
errCurrent[ni + 1] += errG * 7 / 16;
errCurrent[ni + 2] += errB * 7 / 16;
}
if (y + 1 < height && x > 0) {
size_t ni = (x - 1) * 3;
errNext[ni] += errR * 3 / 16;
errNext[ni + 1] += errG * 3 / 16;
errNext[ni + 2] += errB * 3 / 16;
}
if (y + 1 < height) {
size_t ni = x * 3;
errNext[ni] += errR * 5 / 16;
errNext[ni + 1] += errG * 5 / 16;
errNext[ni + 2] += errB * 5 / 16;
}
if (y + 1 < height && x + 1 < width) {
size_t ni = (x + 1) * 3;
errNext[ni] += errR * 1 / 16;
errNext[ni + 1] += errG * 1 / 16;
errNext[ni + 2] += errB * 1 / 16;
if (_blueNoise) {
// Randomized error scatter: the diagonal pattern is caused
// by the 5/16 "below" weight always landing error on the
// same column, creating vertical correlation that manifests
// as diagonal lines. We break this by randomly offsetting
// the entire below-row error target by -2..+2 pixels.
// Energy is perfectly conserved (same 9/16 total, same
// 3/5/1 ratio — just shifted horizontally).
uint32_t h = (uint32_t)x * 2654435761u
^ (uint32_t)y * 2246822519u;
int offset = (int)(h % 5u) - 2; // -2, -1, 0, +1, or +2
int xBL = xLeft + offset;
int xB = x + offset;
int xBR = xRight + offset;
// 3/16 to below-left (shifted)
if (xBL >= 0 && xBL < (int)width) {
size_t ni = (size_t)xBL * 3;
errNext[ni] += errR * 3 / 16;
errNext[ni + 1] += errG * 3 / 16;
errNext[ni + 2] += errB * 3 / 16;
}
// 5/16 to below (shifted)
if (xB >= 0 && xB < (int)width) {
size_t ni = (size_t)xB * 3;
errNext[ni] += errR * 5 / 16;
errNext[ni + 1] += errG * 5 / 16;
errNext[ni + 2] += errB * 5 / 16;
}
// 1/16 to below-right (shifted)
if (xBR >= 0 && xBR < (int)width) {
size_t ni = (size_t)xBR * 3;
errNext[ni] += errR * 1 / 16;
errNext[ni + 1] += errG * 1 / 16;
errNext[ni + 2] += errB * 1 / 16;
}
} else {
// Standard fixed Floyd-Steinberg weights
if (xLeft >= 0 && xLeft < (int)width) {
size_t ni = (size_t)xLeft * 3;
errNext[ni] += errR * 3 / 16;
errNext[ni + 1] += errG * 3 / 16;
errNext[ni + 2] += errB * 3 / 16;
}
{
size_t ni = (size_t)x * 3;
errNext[ni] += errR * 5 / 16;
errNext[ni + 1] += errG * 5 / 16;
errNext[ni + 2] += errB * 5 / 16;
}
if (xRight >= 0 && xRight < (int)width) {
size_t ni = (size_t)xRight * 3;
errNext[ni] += errR * 1 / 16;
errNext[ni + 1] += errG * 1 / 16;
errNext[ni + 2] += errB * 1 / 16;
}
}
}
}
@@ -844,12 +1191,13 @@ uint8_t* ImagePipeline::ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t he
uint8_t ImagePipeline::findNearest(int r, int g, int b) {
uint8_t best = 0;
int bestDist = INT32_MAX;
int32_t bestDist = INT32_MAX;
for (int i = 0; i < DISPLAY_COLORS; i++) {
int dr = r - PALETTE_CALIBRATED[i][0];
int dg = g - PALETTE_CALIBRATED[i][1];
int db = b - PALETTE_CALIBRATED[i][2];
int dist = dr * dr + dg * dg + db * db;
// Rec. 709 luminance-weighted distance (integer weights x10000)
int32_t dist = 2126 * dr * dr + 7152 * dg * dg + 722 * db * db;
if (dist < bestDist) {
bestDist = dist;
best = static_cast<uint8_t>(i);

View File

@@ -18,6 +18,13 @@ struct DecodeContext {
uint16_t bufferHeight; // Height of the output buffer
};
// Pipeline processing mode (epdoptimize recommends one or the other, not both)
enum class PipelineMode : uint8_t {
DYNAMIC, // S-curve tone mapping + saturation boost, no DRC
BALANCED, // Dynamic range compression only, no tone mapping
NONE // No pre-processing, straight to dither
};
class ImagePipeline {
public:
// Process single landscape photo (fit-contain with edge-color letterbox)
@@ -29,15 +36,24 @@ public:
void freeImage(ProcessedImage& img);
void setPipelineMode(PipelineMode mode) { _mode = mode; }
PipelineMode getPipelineMode() const { return _mode; }
void setBlueNoiseEnabled(bool enabled) { _blueNoise = enabled; }
bool getBlueNoiseEnabled() const { return _blueNoise; }
private:
PipelineMode _mode = PipelineMode::DYNAMIC;
bool _blueNoise = true;
// Decode JPEG and fit into target dimensions with letterboxing
uint8_t* decodeAndFit(uint8_t* data, size_t size,
uint16_t targetW, uint16_t targetH,
uint16_t* outW, uint16_t* outH);
// Row-by-row Floyd-Steinberg dither RGB888 to 6-color palette indices
// Serpentine Floyd-Steinberg dither RGB888 to 6-color palette indices
uint8_t* ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t height);
// Find nearest palette color (Euclidean distance in RGB space)
// Find nearest palette color (Rec. 709 luminance-weighted RGB distance)
uint8_t findNearest(int r, int g, int b);
};

View File

@@ -138,6 +138,51 @@ std::vector<String> ImmichClient::fetchRandomAssetIds(int count) {
return ids;
}
std::vector<RandomAsset> ImmichClient::fetchRandomAssets(int count) {
std::vector<RandomAsset> assets;
String url = buildUrl("/api/search/random");
JsonDocument reqDoc;
reqDoc["size"] = count;
reqDoc["type"] = "IMAGE";
String body;
serializeJson(reqDoc, body);
String response = httpPost(url, body);
if (response.isEmpty()) return assets;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) return assets;
JsonArray arr = doc.as<JsonArray>();
int skippedRaw = 0;
for (JsonObject asset : arr) {
String id = asset["id"].as<String>();
if (id.length() == 0) continue;
String filename = asset["originalFileName"] | "";
filename.toLowerCase();
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
filename.endsWith(".nef") || filename.endsWith(".arw") ||
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
filename.endsWith(".raf") || filename.endsWith(".srw")) {
skippedRaw++;
continue;
}
RandomAsset ra;
ra.id = id;
ra.dateTime = asset["localDateTime"] | "";
assets.push_back(ra);
}
Serial.printf("[immich] Fetched %d random assets with dates (skipped %d RAW)\n",
assets.size(), skippedRaw);
return assets;
}
std::vector<String> ImmichClient::fetchFavoriteAssetIds() {
std::vector<String> ids;
String url = buildUrl("/api/assets?isFavorite=true");

View File

@@ -21,12 +21,18 @@ struct AssetInfo {
std::vector<String> people;
};
struct RandomAsset {
String id;
String dateTime;
};
class ImmichClient {
public:
void begin(const String& baseUrl, const String& apiKey);
std::vector<AlbumInfo> fetchAlbums();
std::vector<String> fetchAlbumAssetIds(const String& albumId);
std::vector<String> fetchRandomAssetIds(int count = 50);
std::vector<RandomAsset> fetchRandomAssets(int count = 50);
std::vector<String> fetchFavoriteAssetIds();
AssetInfo fetchAssetInfo(const String& assetId);
bool downloadAsset(const String& assetId, ImageQuality quality,

View File

@@ -61,6 +61,8 @@ void displayTask(void* param) {
while (true) {
unsigned long now = millis();
Settings s = settingsManager.get();
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
unsigned long intervalMs = s.interval_min * 60000UL;
bool shouldRefresh = false;
@@ -160,6 +162,10 @@ void displayTask(void* param) {
rightX, 0, halfW, DISPLAY_HEIGHT,
s.date_fmt, s.time_fmt);
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
@@ -188,6 +194,10 @@ void displayTask(void* param) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
s.date_fmt, s.time_fmt);
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
@@ -222,6 +232,67 @@ void webActionCallback(const String& action) {
}
}
// Minimal boot path for RTC timer wake: fetch one photo, display, sleep again
void timerWakeCycle() {
Serial.println("[timer_wake] Starting minimal wake cycle");
Settings s = settingsManager.get();
// Connect WiFi (blocking)
wifiManager.begin(settingsManager);
if (!wifiManager.isConnected()) {
Serial.println("[timer_wake] WiFi failed — going back to sleep");
powerManager.enterDeepSleep(s.interval_min);
return;
}
// Initialize Immich client and apply pipeline mode
immichClient.begin(s.immich_url, s.immich_key);
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
// Fetch a small batch of random assets
static constexpr int TIMER_WAKE_RETRIES = 5;
bool displayed = false;
std::vector<String> ids = immichClient.fetchRandomAssetIds(10);
for (int i = 0; i < (int)ids.size() && i < TIMER_WAKE_RETRIES && !displayed; i++) {
AssetInfo info = immichClient.fetchAssetInfo(ids[i]);
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
if (!immichClient.downloadAsset(ids[i], s.img_quality, &jpegBuf, &jpegSize)) {
continue;
}
ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize);
free(jpegBuf);
if (img.valid) {
displayManager.showImage(img);
if (s.meta_flags != 0) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
s.date_fmt, s.time_fmt);
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.showSleepIndicator(s.meta_pos);
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
}
}
if (!displayed) {
Serial.println("[timer_wake] Failed to display any photo");
}
Serial.printf("[timer_wake] Cycle complete — sleeping %u min\n", s.interval_min);
powerManager.enterDeepSleep(s.interval_min);
}
void setup() {
auto cfg = M5.config();
cfg.clear_display = false; // Don't clear/refresh e-ink on init
@@ -238,15 +309,41 @@ void setup() {
Serial.printf("[main] Internal RAM: %u KB free\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024));
// Initialize core subsystems needed for both wake paths
powerManager.begin();
settingsManager.begin();
displayManager.begin(powerManager);
// Check if we woke from a timed deep sleep (persisted flag in NVS)
bool timerWoke = PowerManager::wasTimedSleepWake();
if (timerWoke) {
Serial.println("[main] Woke from timed deep sleep");
// Escape hatch: hold play/pause button during boot to stay awake
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
delay(50);
bool pauseHeld = (digitalRead(PIN_BTN_DOWN) == LOW);
if (!pauseHeld) {
// Minimal cycle: show photo and go back to sleep
timerWakeCycle();
// timerWakeCycle() calls enterDeepSleep — should not return
return;
}
// Escape hatch triggered — clear the flag and enter full interactive mode
PowerManager::clearTimedSleepFlag();
Serial.println("[main] Play/pause held — entering full interactive mode");
}
// Full interactive boot path
// Create state mutex
stateMutex = xSemaphoreCreateMutex();
// Initialize subsystems
powerManager.begin();
settingsManager.begin();
// Initialize remaining subsystems
wifiManager.begin(settingsManager);
buttonHandler.begin(powerManager, settingsManager);
displayManager.begin(powerManager);
if (!wifiManager.isAPMode()) {
// Station mode — set up Immich and web server
@@ -304,11 +401,15 @@ void loop() {
}
break;
case ButtonEvent::DeepSleep: {
Serial.println("[main] Entering deep sleep...");
Serial.println("[main] Entering timed deep sleep slideshow...");
Settings sleepS = settingsManager.get();
displayManager.showSleepIndicator(sleepS.meta_pos);
if (sleepS.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, sleepS.meta_pos);
}
displayManager.refresh();
powerManager.enterDeepSleep();
powerManager.enterDeepSleep(sleepS.interval_min);
break;
}
case ButtonEvent::FactoryReset:

View File

@@ -19,30 +19,30 @@ bool PhotoQueue::sync() {
Settings s = _settings->get();
// Fetch in batches until we have TARGET_QUEUE_SIZE usable, non-duplicate IDs
// Fetch in batches until we have TARGET_QUEUE_SIZE usable, non-duplicate IDs.
// Use fetchRandomAssets() to capture dates alongside IDs.
std::vector<String> newIds;
std::vector<String> newDates;
int rounds = 0;
Serial.printf("[queue] Fetching until %d usable assets (excluding %d shown)\n",
TARGET_QUEUE_SIZE, _shown.size());
while (newIds.size() < TARGET_QUEUE_SIZE && rounds < MAX_FETCH_ROUNDS) {
auto batch = _client->fetchRandomAssetIds(50);
auto batch = _client->fetchRandomAssets(50);
if (batch.empty()) {
Serial.printf("[queue] Fetch round %d returned empty\n", rounds + 1);
break;
}
for (auto& id : batch) {
for (auto& asset : batch) {
if (newIds.size() >= TARGET_QUEUE_SIZE) break;
// Skip if already in this batch
if (std::find(newIds.begin(), newIds.end(), id) != newIds.end()) continue;
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
if (std::find(_shown.begin(), _shown.end(), asset.id) != _shown.end()) continue;
// Skip if recently shown
if (std::find(_shown.begin(), _shown.end(), id) != _shown.end()) continue;
newIds.push_back(id);
newIds.push_back(asset.id);
newDates.push_back(asset.dateTime);
}
rounds++;
@@ -66,15 +66,24 @@ bool PhotoQueue::sync() {
shuffle();
break;
case CycleMode::Chronological:
sortChronological(false);
sortChronological(newDates, false);
break;
case CycleMode::ReverseChronological:
sortChronological(true);
sortChronological(newDates, true);
break;
case CycleMode::FavoritesWeighted:
applyFavoritesWeighting();
shuffle();
break;
case CycleMode::WeightedChronological:
applyRecencyWeighting(newDates, true);
shuffle();
break;
case CycleMode::WeightedReverseChronological:
applyRecencyWeighting(newDates, false);
shuffle();
break;
// No default — compiler warns on unhandled CycleMode via -Wswitch
}
// Reset cursor for fresh queue
@@ -166,10 +175,59 @@ void PhotoQueue::shuffle() {
}
}
void PhotoQueue::sortChronological(bool reverse) {
if (reverse) {
std::reverse(_queue.begin(), _queue.end());
void PhotoQueue::sortChronological(const std::vector<String>& dates, bool reverse) {
if (dates.size() != _queue.size()) {
Serial.println("[queue] sortChronological: date/queue size mismatch, skipping");
return;
}
// Build index array sorted by date (ISO 8601 strings compare correctly)
std::vector<size_t> indices(dates.size());
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
return dates[a] < dates[b];
});
if (reverse) std::reverse(indices.begin(), indices.end());
std::vector<String> sorted;
sorted.reserve(_queue.size());
for (size_t idx : indices) {
sorted.push_back(_queue[idx]);
}
_queue = sorted;
}
void PhotoQueue::applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent) {
if (dates.size() != _queue.size() || _queue.size() < 3) return;
// Sort queue by date (oldest first)
std::vector<size_t> indices(dates.size());
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
return dates[a] < dates[b];
});
std::vector<String> sorted;
sorted.reserve(_queue.size());
for (size_t idx : indices) {
sorted.push_back(_queue[idx]);
}
_queue = sorted;
// Duplicate the preferred third (3x copies total)
size_t third = _queue.size() / 3;
size_t start = favorRecent ? _queue.size() - third : 0;
size_t end = favorRecent ? _queue.size() : third;
for (size_t i = start; i < end; i++) {
_queue.push_back(_queue[i]);
_queue.push_back(_queue[i]);
}
Serial.printf("[queue] Recency weighting: %s third duplicated (%d -> %d entries)\n",
favorRecent ? "recent" : "oldest", (int)sorted.size(), (int)_queue.size());
}
void PhotoQueue::applyFavoritesWeighting() {

View File

@@ -35,7 +35,8 @@ private:
static constexpr size_t MAX_SHOWN_HISTORY = 200;
void shuffle();
void sortChronological(bool reverse);
void sortChronological(const std::vector<String>& dates, bool reverse);
void applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent);
void applyFavoritesWeighting();
std::vector<String> getSelectedAlbumIds();
};

View File

@@ -1,7 +1,9 @@
#include "power_manager.h"
#include <M5Unified.h>
#include <Preferences.h>
#include <esp_pm.h>
#include <esp_sleep.h>
static const char* NVS_PM_NAMESPACE = "pm_state";
void PowerManager::begin() {
Serial.println("[power] Initializing power manager");
@@ -84,16 +86,47 @@ void PowerManager::flashLED(uint8_t r, uint8_t g, uint8_t b, uint16_t duration_m
clearLEDs();
}
void PowerManager::enterDeepSleep() {
Serial.println("[power] Entering deep sleep — power button to wake");
void PowerManager::enterDeepSleep(uint32_t sleepMinutes) {
if (sleepMinutes > 0) {
Serial.printf("[power] Entering timed deep sleep — wake in %u min\n", sleepMinutes);
} else {
Serial.println("[power] Entering indefinite deep sleep");
}
Serial.flush();
delay(100);
clearLEDs();
// On PaperColor, deep sleep is managed via M5PM1 shutdown
// The power button is hardwired to M5PM1 and always wakes the device
M5.Power.deepSleep(0); // 0 = indefinite, wake via power button
// Persist flag in NVS so we know on next boot this was a timed sleep
if (sleepMinutes > 0) {
Preferences prefs;
prefs.begin(NVS_PM_NAMESPACE, false);
prefs.putUChar("timed_sleep", 1);
prefs.end();
}
uint64_t sleepUs = (sleepMinutes > 0)
? static_cast<uint64_t>(sleepMinutes) * 60ULL * 1000000ULL
: 0;
// M5.Power.deepSleep handles M5PM1 hardware properly
M5.Power.deepSleep(sleepUs);
}
bool PowerManager::wasTimedSleepWake() {
Preferences prefs;
prefs.begin(NVS_PM_NAMESPACE, true);
uint8_t flag = prefs.getUChar("timed_sleep", 0);
prefs.end();
return flag == 1;
}
void PowerManager::clearTimedSleepFlag() {
Preferences prefs;
prefs.begin(NVS_PM_NAMESPACE, false);
prefs.putUChar("timed_sleep", 0);
prefs.end();
Serial.println("[power] Timed sleep flag cleared");
}
void PowerManager::enableLightSleep() {

View File

@@ -1,6 +1,7 @@
#pragma once
#include <Arduino.h>
#include <esp_sleep.h>
#include "config.h"
class PowerManager {
@@ -10,11 +11,14 @@ public:
bool isCharging();
void updateBatteryLED();
void flashLED(uint8_t r, uint8_t g, uint8_t b, uint16_t duration_ms = LED_PULSE_DURATION_MS);
void enterDeepSleep();
void enterDeepSleep(uint32_t sleepMinutes = 0);
void enableLightSleep();
void disableEPDPower();
void enableEPDPower();
static bool wasTimedSleepWake();
static void clearTimedSleepFlag();
private:
uint8_t _lastBatteryPct = 100;
unsigned long _lastLEDPulse = 0;

View File

@@ -22,7 +22,10 @@ Settings SettingsManager::get() {
s.meta_pos = static_cast<MetaPosition>(readU8("meta_pos", DEFAULT_META_POS));
s.date_fmt = readString("date_fmt", "%Y-%m-%d");
s.time_fmt = readString("time_fmt", "%H:%M:%S");
s.pipeline_mode = readU8("pipe_mode", DEFAULT_PIPELINE_MODE);
s.dither_noise = readU8("dith_noise", DEFAULT_DITHER_NOISE);
s.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS);
s.show_battery = readU8("show_batt", 1);
s.queue_cursor = readU32("queue_cursor", 0);
s.albums_json = readString("albums_json", "[]");
return s;
@@ -40,7 +43,10 @@ void SettingsManager::save(const Settings& s) {
writeU8("meta_pos", static_cast<uint8_t>(s.meta_pos));
writeString("date_fmt", s.date_fmt);
writeString("time_fmt", s.time_fmt);
writeU8("pipe_mode", s.pipeline_mode);
writeU8("dith_noise", s.dither_noise);
writeU8("led_bright", s.led_brightness);
writeU8("show_batt", s.show_battery);
writeU32("queue_cursor", s.queue_cursor);
writeString("albums_json", s.albums_json);
Serial.println("[settings] All settings saved");

View File

@@ -7,7 +7,9 @@ enum class CycleMode : uint8_t {
Random = 0,
Chronological = 1,
ReverseChronological = 2,
FavoritesWeighted = 3
FavoritesWeighted = 3,
WeightedChronological = 4,
WeightedReverseChronological = 5
};
enum class ImageQuality : uint8_t {
@@ -54,8 +56,13 @@ struct Settings {
String date_fmt; // strftime-style, default "%Y-%m-%d"
String time_fmt; // strftime-style, default "%H:%M:%S"
// Image pipeline
uint8_t pipeline_mode; // 0=dynamic, 1=balanced, 2=none
uint8_t dither_noise; // 1=blue noise dither smoothing ON, 0=OFF
// Device
uint8_t led_brightness;
uint8_t show_battery; // 1 = show battery indicator on display
uint32_t queue_cursor;
String albums_json;
};

View File

@@ -154,7 +154,8 @@ void AppWebServer::setupAPIRoutes() {
[this](AsyncWebServerRequest* req) {
req->send(200, "application/json", "{\"ok\":true}");
delay(100);
_power->enterDeepSleep();
Settings s = _settings->get();
_power->enterDeepSleep(s.interval_min);
});
// Test Immich connectivity
@@ -284,7 +285,10 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
doc["meta_pos"] = static_cast<uint8_t>(s.meta_pos);
doc["date_fmt"] = s.date_fmt;
doc["time_fmt"] = s.time_fmt;
doc["pipeline_mode"] = s.pipeline_mode;
doc["dither_noise"] = s.dither_noise;
doc["led_brightness"] = s.led_brightness;
doc["show_battery"] = s.show_battery;
doc["immich_url"] = s.immich_url;
doc["immich_key"] = s.immich_key;
doc["albums_json"] = s.albums_json;
@@ -312,7 +316,10 @@ void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
if (!doc["meta_pos"].isNull()) s.meta_pos = static_cast<MetaPosition>((uint8_t)doc["meta_pos"]);
if (!doc["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as<String>();
if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as<String>();
if (!doc["pipeline_mode"].isNull()) s.pipeline_mode = doc["pipeline_mode"];
if (!doc["dither_noise"].isNull()) s.dither_noise = doc["dither_noise"];
if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"];
if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as<String>();
if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as<String>();

View File

@@ -5,27 +5,27 @@
#include <cstring>
#include <climits>
// Palette definition (same as config.h)
// Calibrated Spectra 6 palette (matches PALETTE_CALIBRATED in image_pipeline.cpp)
struct Color { uint8_t r, g, b; };
static const Color PALETTE[6] = {
{0, 0, 0}, // Black
{255, 255, 255}, // White
{200, 30, 30}, // Red
{30, 160, 30}, // Green
{30, 30, 200}, // Blue
{220, 200, 30} // Yellow
{0x1F, 0x22, 0x26}, // Black -> dark gray
{0xB9, 0xC7, 0xC9}, // White -> light gray-blue
{0x62, 0x20, 0x1E}, // Red -> dark red/brown
{0x35, 0x56, 0x3A}, // Green -> dark forest green
{0x23, 0x3F, 0x8E}, // Blue -> dark navy
{0xC1, 0xBB, 0x1E} // Yellow -> olive/mustard
};
// Find nearest palette color (Euclidean distance in RGB)
// Find nearest palette color (Rec. 709 luminance-weighted RGB distance)
uint8_t findNearestColor(int r, int g, int b) {
uint8_t best = 0;
int bestDist = INT32_MAX;
int32_t bestDist = INT32_MAX;
for (int i = 0; i < 6; i++) {
int dr = r - PALETTE[i].r;
int dg = g - PALETTE[i].g;
int db = b - PALETTE[i].b;
int dist = dr*dr + dg*dg + db*db;
int32_t dist = 2126 * dr * dr + 7152 * dg * dg + 722 * db * db;
if (dist < bestDist) {
bestDist = dist;
best = static_cast<uint8_t>(i);
@@ -150,3 +150,145 @@ void test_dither_produces_valid_indices() {
TEST_ASSERT_TRUE(output[i] < 6); // Valid palette index
}
}
// Include the actual blue noise texture for testing
#include "../../src/blue_noise.h"
static constexpr float TEST_BLUE_NOISE_STRENGTH = 32.0f;
// Floyd-Steinberg dither with blue noise threshold modulation
void ditherBufferWithBlueNoise(uint8_t* rgb, int width, int height, uint8_t* output) {
int16_t* work = (int16_t*)malloc(width * height * 3 * sizeof(int16_t));
for (int i = 0; i < width * height * 3; i++) {
work[i] = rgb[i];
}
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int idx = (y * width + x) * 3;
int r = work[idx];
int g = work[idx + 1];
int b = work[idx + 2];
r = r < 0 ? 0 : (r > 255 ? 255 : r);
g = g < 0 ? 0 : (g > 255 ? 255 : g);
b = b < 0 ? 0 : (b > 255 ? 255 : b);
// Apply blue noise perturbation
uint8_t noise = pgm_read_byte(&BLUE_NOISE_64[(y & 63) * 64 + (x & 63)]);
float nf = ((float)noise - 128.0f) * (TEST_BLUE_NOISE_STRENGTH / 128.0f);
int rn = r + (int)nf;
int gn = g + (int)nf;
int bn = b + (int)nf;
rn = rn < 0 ? 0 : (rn > 255 ? 255 : rn);
gn = gn < 0 ? 0 : (gn > 255 ? 255 : gn);
bn = bn < 0 ? 0 : (bn > 255 ? 255 : bn);
uint8_t nearest = findNearestColor(rn, gn, bn);
output[y * width + x] = nearest;
// Error uses original r,g,b (energy conservation)
int errR = r - PALETTE[nearest].r;
int errG = g - PALETTE[nearest].g;
int errB = b - PALETTE[nearest].b;
if (x + 1 < width) {
int ni = (y * width + (x + 1)) * 3;
work[ni] += errR * 7 / 16;
work[ni + 1] += errG * 7 / 16;
work[ni + 2] += errB * 7 / 16;
}
if (y + 1 < height) {
if (x > 0) {
int ni = ((y + 1) * width + (x - 1)) * 3;
work[ni] += errR * 3 / 16;
work[ni + 1] += errG * 3 / 16;
work[ni + 2] += errB * 3 / 16;
}
{
int ni = ((y + 1) * width + x) * 3;
work[ni] += errR * 5 / 16;
work[ni + 1] += errG * 5 / 16;
work[ni + 2] += errB * 5 / 16;
}
if (x + 1 < width) {
int ni = ((y + 1) * width + (x + 1)) * 3;
work[ni] += errR * 1 / 16;
work[ni + 1] += errG * 1 / 16;
work[ni + 2] += errB * 1 / 16;
}
}
}
}
free(work);
}
void test_blue_noise_breaks_pattern_regularity() {
// Use a 16x16 dark gray patch — just above the black palette value.
// Standard F-S produces highly regular dot patterns in this region.
const int W = 16, H = 16;
uint8_t rgb[W * H * 3];
memset(rgb, 45, sizeof(rgb)); // Dark gray (45,45,45) — triggers error accumulation
uint8_t output_standard[W * H];
uint8_t output_blue_noise[W * H];
// Copy rgb since ditherBuffer modifies working buffer
uint8_t rgb_copy[W * H * 3];
memcpy(rgb_copy, rgb, sizeof(rgb));
ditherBuffer(rgb, W, H, output_standard);
ditherBufferWithBlueNoise(rgb_copy, W, H, output_blue_noise);
// Both should produce valid palette indices
for (int i = 0; i < W * H; i++) {
TEST_ASSERT_TRUE(output_standard[i] < 6);
TEST_ASSERT_TRUE(output_blue_noise[i] < 6);
}
// Count unique rows in each output to measure pattern regularity.
// Standard F-S in uniform areas tends to produce repeating row patterns.
// Blue noise should produce more unique rows (less periodic).
int unique_standard = 0;
int unique_blue_noise = 0;
for (int y = 0; y < H; y++) {
bool is_duplicate = false;
for (int prev = 0; prev < y; prev++) {
if (memcmp(&output_standard[y * W], &output_standard[prev * W], W) == 0) {
is_duplicate = true;
break;
}
}
if (!is_duplicate) unique_standard++;
}
for (int y = 0; y < H; y++) {
bool is_duplicate = false;
for (int prev = 0; prev < y; prev++) {
if (memcmp(&output_blue_noise[y * W], &output_blue_noise[prev * W], W) == 0) {
is_duplicate = true;
break;
}
}
if (!is_duplicate) unique_blue_noise++;
}
// Blue noise version should have at least as many unique rows as standard.
// In practice it should have more (the whole point of the feature).
TEST_ASSERT_GREATER_OR_EQUAL(unique_standard, unique_blue_noise);
}
void test_blue_noise_dither_valid_indices() {
const int W = 16, H = 16;
uint8_t rgb[W * H * 3];
// Test across different gray levels
for (int level = 0; level < 256; level += 32) {
memset(rgb, level, sizeof(rgb));
uint8_t output[W * H];
ditherBufferWithBlueNoise(rgb, W, H, output);
for (int i = 0; i < W * H; i++) {
TEST_ASSERT_TRUE(output[i] < 6);
}
}
}

View File

@@ -101,6 +101,8 @@ void test_nearest_color_yellow();
void test_dither_solid_black();
void test_dither_solid_white();
void test_dither_produces_valid_indices();
void test_blue_noise_breaks_pattern_regularity();
void test_blue_noise_dither_valid_indices();
void setUp() {}
void tearDown() {}
@@ -120,6 +122,8 @@ int main() {
RUN_TEST(test_dither_solid_black);
RUN_TEST(test_dither_solid_white);
RUN_TEST(test_dither_produces_valid_indices);
RUN_TEST(test_blue_noise_breaks_pattern_regularity);
RUN_TEST(test_blue_noise_dither_valid_indices);
UNITY_END();
return 0;
}