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
@@ -163,16 +174,24 @@ async function renderDisplay(el) {
+
`;
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;
}