diff --git a/data/app.js b/data/app.js index 6e4704f..992edec 100644 --- a/data/app.js +++ b/data/app.js @@ -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 = `

Interval

@@ -131,6 +132,16 @@ async function renderDisplay(el) {
+
+

Image Processing

+
+ +
+

Metadata Overlay

+
+

Battery Indicator

+ +
`; 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'); }; diff --git a/platformio.ini b/platformio.ini index 104a7c8..e95ce51 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/blue_noise.h b/src/blue_noise.h new file mode 100644 index 0000000..3f95c7e --- /dev/null +++ b/src/blue_noise.h @@ -0,0 +1,83 @@ +#pragma once + +#ifdef ARDUINO +#include +#else +#ifndef PROGMEM +#define PROGMEM +#endif +#ifndef pgm_read_byte +#define pgm_read_byte(addr) (*(const uint8_t*)(addr)) +#endif +#include +#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 +}; diff --git a/src/config.h b/src/config.h index 444193f..bcdb9be 100644 --- a/src/config.h +++ b/src/config.h @@ -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 diff --git a/src/display_manager.cpp b/src/display_manager.cpp index e319aa4..b4d435c 100644 --- a/src/display_manager.cpp +++ b/src/display_manager.cpp @@ -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() { diff --git a/src/display_manager.h b/src/display_manager.h index 20d60b2..e81e1cb 100644 --- a/src/display_manager.h +++ b/src/display_manager.h @@ -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); }; diff --git a/src/image_pipeline.cpp b/src/image_pipeline.cpp index 1b1696e..019c62b 100644 --- a/src/image_pipeline.cpp +++ b/src/image_pipeline.cpp @@ -1,4 +1,5 @@ #include "image_pipeline.h" +#include "blue_noise.h" #include #include #include @@ -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(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(i); diff --git a/src/image_pipeline.h b/src/image_pipeline.h index 2979a9f..6ec45c7 100644 --- a/src/image_pipeline.h +++ b/src/image_pipeline.h @@ -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); }; diff --git a/src/immich_client.cpp b/src/immich_client.cpp index ab6976f..0b1f64f 100644 --- a/src/immich_client.cpp +++ b/src/immich_client.cpp @@ -138,6 +138,51 @@ std::vector ImmichClient::fetchRandomAssetIds(int count) { return ids; } +std::vector ImmichClient::fetchRandomAssets(int count) { + std::vector 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(); + int skippedRaw = 0; + for (JsonObject asset : arr) { + String id = asset["id"].as(); + 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 ImmichClient::fetchFavoriteAssetIds() { std::vector ids; String url = buildUrl("/api/assets?isFavorite=true"); diff --git a/src/immich_client.h b/src/immich_client.h index 9dccab1..57d5001 100644 --- a/src/immich_client.h +++ b/src/immich_client.h @@ -21,12 +21,18 @@ struct AssetInfo { std::vector people; }; +struct RandomAsset { + String id; + String dateTime; +}; + class ImmichClient { public: void begin(const String& baseUrl, const String& apiKey); std::vector fetchAlbums(); std::vector fetchAlbumAssetIds(const String& albumId); std::vector fetchRandomAssetIds(int count = 50); + std::vector fetchRandomAssets(int count = 50); std::vector fetchFavoriteAssetIds(); AssetInfo fetchAssetInfo(const String& assetId); bool downloadAsset(const String& assetId, ImageQuality quality, diff --git a/src/main.cpp b/src/main.cpp index e366538..aa8577a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -61,6 +61,8 @@ void displayTask(void* param) { while (true) { unsigned long now = millis(); Settings s = settingsManager.get(); + imagePipeline.setPipelineMode(static_cast(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(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 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: diff --git a/src/photo_queue.cpp b/src/photo_queue.cpp index ea0dd73..bf86d96 100644 --- a/src/photo_queue.cpp +++ b/src/photo_queue.cpp @@ -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 newIds; + std::vector 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& 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 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 sorted; + sorted.reserve(_queue.size()); + for (size_t idx : indices) { + sorted.push_back(_queue[idx]); + } + _queue = sorted; +} + +void PhotoQueue::applyRecencyWeighting(const std::vector& dates, bool favorRecent) { + if (dates.size() != _queue.size() || _queue.size() < 3) return; + + // Sort queue by date (oldest first) + std::vector 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 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() { diff --git a/src/photo_queue.h b/src/photo_queue.h index d5c0201..65f739d 100644 --- a/src/photo_queue.h +++ b/src/photo_queue.h @@ -35,7 +35,8 @@ private: static constexpr size_t MAX_SHOWN_HISTORY = 200; void shuffle(); - void sortChronological(bool reverse); + void sortChronological(const std::vector& dates, bool reverse); + void applyRecencyWeighting(const std::vector& dates, bool favorRecent); void applyFavoritesWeighting(); std::vector getSelectedAlbumIds(); }; diff --git a/src/power_manager.cpp b/src/power_manager.cpp index 4a7341a..033e366 100644 --- a/src/power_manager.cpp +++ b/src/power_manager.cpp @@ -1,7 +1,9 @@ #include "power_manager.h" #include +#include #include -#include + +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(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() { diff --git a/src/power_manager.h b/src/power_manager.h index 3070e7f..655e30c 100644 --- a/src/power_manager.h +++ b/src/power_manager.h @@ -1,6 +1,7 @@ #pragma once #include +#include #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; diff --git a/src/settings.cpp b/src/settings.cpp index d2b1c6a..4ff8c4e 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -22,7 +22,10 @@ Settings SettingsManager::get() { s.meta_pos = static_cast(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(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"); diff --git a/src/settings.h b/src/settings.h index b134e61..eed0163 100644 --- a/src/settings.h +++ b/src/settings.h @@ -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; }; diff --git a/src/web_server.cpp b/src/web_server.cpp index 9dbf48d..1bfcc24 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -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(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((uint8_t)doc["meta_pos"]); if (!doc["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as(); if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as(); + 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(); if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as(); diff --git a/test/test_native/test_image_pipeline.cpp b/test/test_native/test_image_pipeline.cpp index ee855cc..68d2036 100644 --- a/test/test_native/test_image_pipeline.cpp +++ b/test/test_native/test_image_pipeline.cpp @@ -5,27 +5,27 @@ #include #include -// 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(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); + } + } +} diff --git a/test/test_native/test_photo_queue.cpp b/test/test_native/test_photo_queue.cpp index b452177..df85738 100644 --- a/test/test_native/test_photo_queue.cpp +++ b/test/test_native/test_photo_queue.cpp @@ -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; }