Skip to content

Instantly share code, notes, and snippets.

@Yuikawa-Akira
Last active August 18, 2026 18:53
Show Gist options
  • Select an option

  • Save Yuikawa-Akira/91901d3ba193a8f4347e1fc6ffd24f11 to your computer and use it in GitHub Desktop.

Select an option

Save Yuikawa-Akira/91901d3ba193a8f4347e1fc6ffd24f11 to your computer and use it in GitHub Desktop.
PaperColor_SHT40_SD_Viewer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M5PaperColor Dithering Simulator</title>
<style>
:root {
--bg: #16171a;
--panel: #1e2024;
--panel-2: #26282e;
--border: #34363c;
--text: #e8e6e1;
--text-dim: #9a9a9a;
--accent: #e0b04a;
--accent-dim: #6b5629;
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
}
header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
}
header h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
.layout {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 20px;
padding: 20px 24px 40px;
align-items: start;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.panel + .panel { margin-top: 16px; }
.panel h2 {
margin: 0 0 12px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 600;
}
.file-drop {
border: 1.5px dashed var(--border);
border-radius: 8px;
padding: 18px 12px;
text-align: center;
cursor: pointer;
color: var(--text-dim);
font-size: 12.5px;
transition: border-color .15s;
}
.file-drop:hover { border-color: var(--accent-dim); }
.file-drop input { display: none; }
.field { margin-top: 14px; }
.field label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-dim);
margin-bottom: 4px;
}
.field label span.val {
color: var(--accent);
font-family: var(--mono);
}
input[type=range] {
width: 100%;
accent-color: var(--accent);
}
.dims-row { display: flex; gap: 8px; }
.dims-row input[type=number] {
width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 6px 8px;
font-family: var(--mono);
font-size: 13px;
}
select {
width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 6px 8px;
font-family: var(--mono);
font-size: 13px;
}
.tabs {
display: flex;
gap: 4px;
background: var(--panel-2);
border-radius: 8px;
padding: 3px;
}
.tabs button {
flex: 1;
padding: 8px 4px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-dim);
font-size: 12.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.tabs button.active {
background: var(--accent);
color: #1a1300;
font-weight: 600;
}
button.reset {
margin-top: 12px;
width: 100%;
padding: 7px;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
button.reset:hover { color: var(--text); border-color: var(--accent-dim); }
.compare {
width: 600px;
}
.before-after {
position: relative;
width: 600px;
height: 400px;
background: var(--panel-2);
overflow: hidden;
}
.preview-layer,
.preview-overlay {
position: absolute;
inset: 0;
}
.preview-overlay {
width: 50%;
overflow: hidden;
box-shadow: inset -2px 0 var(--accent);
}
#ditherStage canvas,
#refStage canvas {
display: block;
width: 600px !important;
height: 400px !important;
}
.compare-slider {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: ew-resize;
z-index: 3;
}
.compare-handle {
position: absolute;
top: 50%;
left: 50%;
width: 28px;
height: 28px;
margin: -14px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--accent);
color: #1a1300;
font-size: 14px;
font-weight: 700;
pointer-events: none;
z-index: 2;
}
.compare-label {
display: flex;
justify-content: space-between;
padding: 8px 2px;
color: var(--text-dim);
font-size: 11px;
}
.download-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
canvas {
max-width: none;
height: auto;
image-rendering: pixelated;
border-radius: 3px;
}
.empty-msg {
color: var(--text-dim);
font-size: 12.5px;
text-align: center;
padding: 30px 10px;
}
#origStage canvas {
width: 100% !important;
height: auto;
}
.source-download { margin-top: 8px; }
.perf-badge {
font-family: var(--mono);
color: var(--text-dim);
}
.download {
width: 100%;
border: 0;
border-top: 1px solid var(--border);
padding: 8px;
background: transparent;
color: var(--accent);
cursor: pointer;
font-size: 11px;
}
.download:hover { background: var(--panel); }
</style>
</head>
<body>
<header>
<h1>M5PaperColor Dithering Simulator</h1>
</header>
<div class="layout">
<!-- LEFT: controls -->
<aside>
<div class="panel">
<h2>Image</h2>
<label class="file-drop" id="dropZone">
<div id="origStage"><div class="empty-msg">Select or drop an image</div></div>
<input type="file" id="fileInput" accept="image/png,image/jpeg">
</label>
<button class="download source-download" data-stage="origStage" data-name="original.png">Download PNG</button>
<div class="field">
<label>Output size</label>
<div class="dims-row">
<input type="number" id="outW" value="600" min="16" max="1200" step="1">
<input type="number" id="outH" value="400" min="16" max="1200" step="1">
</div>
</div>
</div>
<div class="panel">
<h2>Dithering</h2>
<div class="tabs">
<button data-algo="fs" class="active">Floyd–Steinberg</button>
<button data-algo="atk">Atkinson</button>
<button data-algo="bayer">Bayer</button>
</div>
<div id="algoParams"></div>
<button class="reset" id="resetBtn">Reset</button>
</div>
</aside>
<main>
<div class="panel">
<h2>Preview <span class="perf-badge" id="perfBadge"></span></h2>
<div class="compare">
<div class="before-after">
<div class="preview-layer" id="ditherStage"><div class="empty-msg">Select an image</div></div>
<div class="preview-overlay" id="einkClip"><div id="refStage"></div></div>
<div class="compare-handle" id="compareHandle"></div>
<input class="compare-slider" id="compareSlider" type="range" min="0" max="100" value="50" aria-label="Compare dithered and E Ink previews">
</div>
<div class="compare-label"><span>E Ink</span><span>Dithered <span id="algoLabel"></span></span></div>
<div class="download-row">
<button class="download" data-stage="refStage" data-name="eink-preview.png">Download E Ink</button>
<button class="download" data-stage="ditherStage" data-name="dithered.png">Download Dithered</button>
</div>
</div>
</div>
</main>
</div>
<script>
(() => {
'use strict';
const PALETTE = [
{ name: 'White', r: 255, g: 255, b: 255 },
{ name: 'Black', r: 0, g: 0, b: 0 },
{ name: 'Red', r: 255, g: 0, b: 0 },
{ name: 'Green', r: 0, g: 255, b: 0 },
{ name: 'Blue', r: 0, g: 0, b: 255 },
{ name: 'Yellow', r: 255, g: 255, b: 0 },
];
const PALETTE_REAL_REF = [
{ name: 'White', r: 192, g: 200, b: 200 },
{ name: 'Black', r: 4, g: 4, b: 4 },
{ name: 'Red', r: 140, g: 20, b: 0 },
{ name: 'Green', r: 40, g: 108, b: 60 },
{ name: 'Blue', r: 4, g: 64, b: 160 },
{ name: 'Yellow', r: 204, g: 200, b: 0 },
];
function quantizeTo565(r, g, b) {
const r5 = Math.min(31, Math.floor(r / 8));
const g6 = Math.min(63, Math.floor(g / 4));
const b5 = Math.min(31, Math.floor(b / 8));
return {
r: r5 * 255 / 31,
g: g6 * 255 / 63,
b: b5 * 255 / 31,
};
}
function rgbToHsv(r, g, b) {
const minV = Math.min(r, g, b);
const maxV = Math.max(r, g, b);
const delta = maxV - minV;
const v = maxV / 255;
if (maxV < 1e-5 || delta < 1e-5) return { h: 0, s: 0, v };
const s = delta / maxV;
let h;
if (r >= maxV) h = (g - b) / delta;
else if (g >= maxV) h = 2 + (b - r) / delta;
else h = 4 + (r - g) / delta;
h *= 60;
if (h < 0) h += 360;
return { h, s, v };
}
function hsvToRgb(h, s, v) {
if (s <= 0) { const c = v * 255; return { r: c, g: c, b: c }; }
let hh = h;
if (hh >= 360) hh = 0;
hh /= 60;
const i = Math.floor(hh);
const ff = hh - i;
const p = v * (1 - s);
const q = v * (1 - s * ff);
const t = v * (1 - s * (1 - ff));
let r, g, b;
switch (i) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; break;
}
return { r: r * 255, g: g * 255, b: b * 255 };
}
function weightedRgbDistance(r1, g1, b1, r2, g2, b2) {
const dr = r1 - r2, dg = g1 - g2, db = b1 - b2;
return 2 * dr * dr + 4 * dg * dg + 3 * db * db;
}
function findClosestColorIndex(r, g, b) {
let closestIdx = 0, minDist = Infinity;
for (let i = 0; i < PALETTE.length; i++) {
const p = PALETTE[i];
const dist = weightedRgbDistance(r, g, b, p.r, p.g, p.b);
if (dist < minDist) { minDist = dist; closestIdx = i; }
}
return closestIdx;
}
function applyFloydSteinberg(src, params) {
const { width: w, height: h, data } = src;
const { saturationBoost, gamma, strength } = params;
const errorStrength = strength / 100;
const buf = new Float32Array(w * h * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const si = (y * w + x) * 4;
let { r, g, b } = quantizeTo565(data[si], data[si + 1], data[si + 2]);
r = 255 * Math.pow(r / 255, gamma);
g = 255 * Math.pow(g / 255, gamma);
b = 255 * Math.pow(b / 255, gamma);
let { h: hue, s: sat, v: val } = rgbToHsv(r, g, b);
sat = Math.min(1, sat * saturationBoost);
({ r, g, b } = hsvToRgb(hue, sat, val));
const bi = (y * w + x) * 3;
buf[bi] = r; buf[bi + 1] = g; buf[bi + 2] = b;
}
}
const out = new Uint8ClampedArray(w * h * 4);
const indices = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const bi = (y * w + x) * 3;
const curR = clampByte(buf[bi] + 0.5);
const curG = clampByte(buf[bi + 1] + 0.5);
const curB = clampByte(buf[bi + 2] + 0.5);
const palIdx = findClosestColorIndex(curR, curG, curB);
const nearest = PALETTE[palIdx];
indices[y * w + x] = palIdx;
const oi = (y * w + x) * 4;
out[oi] = nearest.r; out[oi + 1] = nearest.g; out[oi + 2] = nearest.b; out[oi + 3] = 255;
const errR = curR - nearest.r, errG = curG - nearest.g, errB = curB - nearest.b;
const addErr = (nx, ny, weight) => {
if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
const nbi = (ny * w + nx) * 3;
buf[nbi] += errR * weight * errorStrength; buf[nbi + 1] += errG * weight * errorStrength; buf[nbi + 2] += errB * weight * errorStrength;
}
};
addErr(x + 1, y, 7 / 16);
addErr(x - 1, y + 1, 3 / 16);
addErr(x, y + 1, 5 / 16);
addErr(x + 1, y + 1, 1 / 16);
}
}
return { imageData: new ImageData(out, w, h), indices, w, h };
}
function applyAtkinson(src, params) {
const { width: w, height: h, data } = src;
const { saturationBoost, gamma, strength } = params;
const errorStrength = strength / 100;
const buf = new Float32Array(w * h * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const si = (y * w + x) * 4;
let { r, g, b } = quantizeTo565(data[si], data[si + 1], data[si + 2]);
r = 255 * Math.pow(r / 255, gamma);
g = 255 * Math.pow(g / 255, gamma);
b = 255 * Math.pow(b / 255, gamma);
let { h: hue, s: sat, v: val } = rgbToHsv(r, g, b);
sat = Math.min(1, sat * saturationBoost);
({ r, g, b } = hsvToRgb(hue, sat, val));
const bi = (y * w + x) * 3;
buf[bi] = r; buf[bi + 1] = g; buf[bi + 2] = b;
}
}
const out = new Uint8ClampedArray(w * h * 4);
const indices = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const bi = (y * w + x) * 3;
const curR = clampByte(buf[bi] + 0.5);
const curG = clampByte(buf[bi + 1] + 0.5);
const curB = clampByte(buf[bi + 2] + 0.5);
const palIdx = findClosestColorIndex(curR, curG, curB);
const nearest = PALETTE[palIdx];
indices[y * w + x] = palIdx;
const oi = (y * w + x) * 4;
out[oi] = nearest.r; out[oi + 1] = nearest.g; out[oi + 2] = nearest.b; out[oi + 3] = 255;
const errR = (curR - nearest.r) * errorStrength / 8, errG = (curG - nearest.g) * errorStrength / 8, errB = (curB - nearest.b) * errorStrength / 8;
const addErr = (nx, ny) => {
if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
const nbi = (ny * w + nx) * 3;
buf[nbi] += errR; buf[nbi + 1] += errG; buf[nbi + 2] += errB;
}
};
addErr(x + 1, y); addErr(x + 2, y);
addErr(x - 1, y + 1); addErr(x, y + 1); addErr(x + 1, y + 1);
addErr(x, y + 2);
}
}
return { imageData: new ImageData(out, w, h), indices, w, h };
}
const BAYER_MATRICES = {
2: [
[0, 2],
[3, 1],
],
3: [
[0, 7, 3],
[6, 5, 2],
[4, 1, 8],
],
4: [
[0, 8, 2, 10],
[12, 4, 14, 6],
[3, 11, 1, 9],
[15, 7, 13, 5],
],
8: [
[0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
],
};
function ditherChannel(channel, matrixValue, matrixArea, strength) {
const bias = Math.floor(matrixValue * strength * 2 / matrixArea) - strength;
const biased = Math.max(0, Math.min(255, channel + bias));
const level = Math.floor((biased * 7 + 127) / 255);
return Math.floor(level * 255 / 7);
}
function applyBayer(src, params) {
const { width: w, height: h, data } = src;
const { matrixSize, strength, saturationBoost, gamma } = params;
const matrix = BAYER_MATRICES[matrixSize];
const matrixArea = matrixSize * matrixSize;
const out = new Uint8ClampedArray(w * h * 4);
const indices = new Uint8Array(w * h);
for (let y = 0; y < h; ++y) {
for (let x = 0; x < w; ++x) {
const si = (y * w + x) * 4;
let { r, g, b } = quantizeTo565(data[si], data[si + 1], data[si + 2]);
r = 255 * Math.pow(Math.max(0, r) / 255, gamma);
g = 255 * Math.pow(Math.max(0, g) / 255, gamma);
b = 255 * Math.pow(Math.max(0, b) / 255, gamma);
let { h: hue, s: sat, v: val } = rgbToHsv(r, g, b);
sat = Math.min(1, sat * saturationBoost);
({ r, g, b } = hsvToRgb(hue, sat, val));
const curR = clampByte(r + 0.5), curG = clampByte(g + 0.5), curB = clampByte(b + 0.5);
const matrixValue = matrix[y % matrixSize][x % matrixSize];
const ditheredR = ditherChannel(curR, matrixValue, matrixArea, strength);
const ditheredG = ditherChannel(curG, matrixValue, matrixArea, strength);
const ditheredB = ditherChannel(curB, matrixValue, matrixArea, strength);
const chosenIdx = findClosestColorIndex(ditheredR, ditheredG, ditheredB);
const chosen = PALETTE[chosenIdx];
indices[y * w + x] = chosenIdx;
const oi = (y * w + x) * 4;
out[oi] = chosen.r; out[oi + 1] = chosen.g; out[oi + 2] = chosen.b; out[oi + 3] = 255;
}
}
return { imageData: new ImageData(out, w, h), indices, w, h };
}
function clampByte(v) { return v < 0 ? 0 : v > 255 ? 255 : v | 0; }
const ALGO_DEFS = {
fs: {
label: 'Floyd–Steinberg',
defaults: { strength: 50, saturationBoost: 1.2, gamma: 0.85 },
params: [
{ key: 'strength', label: 'Strength', min: 10, max: 90, step: 1 },
{ key: 'saturationBoost', label: 'Saturation', min: 1.0, max: 3.0, step: 0.05 },
{ key: 'gamma', label: 'Gamma', min: 0.4, max: 1.5, step: 0.01 },
],
run: (src, p) => applyFloydSteinberg(src, p),
},
atk: {
label: 'Atkinson',
defaults: { strength: 50, saturationBoost: 1.2, gamma: 0.85 },
params: [
{ key: 'strength', label: 'Strength', min: 10, max: 90, step: 1 },
{ key: 'saturationBoost', label: 'Saturation', min: 1.0, max: 3.0, step: 0.05 },
{ key: 'gamma', label: 'Gamma', min: 0.4, max: 1.5, step: 0.01 },
],
run: (src, p) => applyAtkinson(src, p),
},
bayer: {
label: 'Bayer',
defaults: { matrixSize: 4, strength: 50, saturationBoost: 1.2, gamma: 0.85 },
params: [
{ key: 'matrixSize', label: 'Matrix size', type: 'select', options: [2, 3, 4, 8] },
{ key: 'strength', label: 'Strength', min: 10, max: 150, step: 1 },
{ key: 'saturationBoost', label: 'Saturation', min: 1.0, max: 3.0, step: 0.05 },
{ key: 'gamma', label: 'Gamma', min: 0.4, max: 1.5, step: 0.01 },
],
run: (src, p) => applyBayer(src, p),
},
};
let currentAlgo = 'fs';
const paramValues = {};
for (const key in ALGO_DEFS) paramValues[key] = { ...ALGO_DEFS[key].defaults };
let sourceImage = null; // HTMLImageElement
let scaledSourceData = null; // ImageData, letterboxed onto output canvas size
let debounceTimer = null;
const algoParamsEl = document.getElementById('algoParams');
const algoLabelEl = document.getElementById('algoLabel');
const perfBadgeEl = document.getElementById('perfBadge');
function buildParamControls() {
const def = ALGO_DEFS[currentAlgo];
algoLabelEl.textContent = def.label;
algoParamsEl.innerHTML = '';
for (const p of def.params) {
const wrap = document.createElement('div');
wrap.className = 'field';
const val = paramValues[currentAlgo][p.key];
if (p.type === 'select') {
wrap.innerHTML = `
<label>${p.label}</label>
<select data-key="${p.key}">${p.options.map(option =>
`<option value="${option}"${option === val ? ' selected' : ''}>${option}×${option}</option>`
).join('')}</select>
`;
algoParamsEl.appendChild(wrap);
continue;
}
wrap.innerHTML = `
<label>${p.label}<span class="val" id="v_${p.key}">${val}</span></label>
<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step}" value="${val}">
`;
algoParamsEl.appendChild(wrap);
}
algoParamsEl.querySelectorAll('input[type=range]').forEach(input => {
input.addEventListener('input', () => {
const key = input.dataset.key;
paramValues[currentAlgo][key] = parseFloat(input.value);
document.getElementById('v_' + key).textContent = input.value;
scheduleRender();
});
});
algoParamsEl.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
paramValues[currentAlgo][select.dataset.key] = parseInt(select.value, 10);
scheduleRender();
});
});
}
document.querySelectorAll('.tabs button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tabs button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentAlgo = btn.dataset.algo;
buildParamControls();
scheduleRender();
});
});
document.getElementById('resetBtn').addEventListener('click', () => {
paramValues[currentAlgo] = { ...ALGO_DEFS[currentAlgo].defaults };
buildParamControls();
scheduleRender();
});
const outWInput = document.getElementById('outW');
const outHInput = document.getElementById('outH');
[outWInput, outHInput].forEach(el => el.addEventListener('change', () => {
if (sourceImage) prepareScaledSource();
scheduleRender();
}));
const fileInput = document.getElementById('fileInput');
const dropZone = document.getElementById('dropZone');
fileInput.addEventListener('change', () => {
if (fileInput.files && fileInput.files[0]) loadFile(fileInput.files[0]);
});
dropZone.addEventListener('dragover', e => { e.preventDefault(); });
dropZone.addEventListener('drop', e => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]);
});
function loadFile(file) {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
sourceImage = img;
prepareScaledSource();
scheduleRender();
URL.revokeObjectURL(url);
};
img.src = url;
}
function prepareScaledSource() {
const outW = Math.max(1, parseInt(outWInput.value, 10));
const outH = Math.max(1, parseInt(outHInput.value, 10));
const scale = Math.min(outW / sourceImage.width, outH / sourceImage.height);
const dw = sourceImage.width * scale;
const dh = sourceImage.height * scale;
const dx = (outW - dw) / 2;
const dy = (outH - dh) / 2;
const cv = document.createElement('canvas');
cv.width = outW; cv.height = outH;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, outW, outH);
ctx.drawImage(sourceImage, dx, dy, dw, dh);
scaledSourceData = ctx.getImageData(0, 0, outW, outH);
renderCanvasInto(document.getElementById('origStage'), scaledSourceData, true);
}
function renderCanvasInto(stageEl, imageData, reduced = false) {
stageEl.innerHTML = '';
const cv = document.createElement('canvas');
cv.width = imageData.width;
cv.height = imageData.height;
cv.style.width = reduced ? '100%' : imageData.width + 'px';
cv.getContext('2d').putImageData(imageData, 0, 0);
stageEl.appendChild(cv);
}
let lastResult = null; // { indices, w, h } from the most recent dither run
function scheduleRender() {
if (!scaledSourceData) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runDither, 80);
}
function runDither() {
const def = ALGO_DEFS[currentAlgo];
const params = { ...paramValues[currentAlgo] };
const t0 = performance.now();
const result = def.run(scaledSourceData, params);
const t1 = performance.now();
perfBadgeEl.textContent = `(${(t1 - t0).toFixed(0)} ms)`;
renderCanvasInto(document.getElementById('ditherStage'), result.imageData);
lastResult = result;
renderReferencePreview();
}
function renderReferencePreview() {
if (!lastResult) return;
const { indices, w, h } = lastResult;
const out = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w * h; i++) {
const c = PALETTE_REAL_REF[indices[i]];
const oi = i * 4;
out[oi] = c.r; out[oi + 1] = c.g; out[oi + 2] = c.b; out[oi + 3] = 255;
}
renderCanvasInto(document.getElementById('refStage'), new ImageData(out, w, h));
}
document.querySelectorAll('.download').forEach(button => {
button.addEventListener('click', () => {
const canvas = document.querySelector(`#${button.dataset.stage} canvas`);
if (!canvas) return;
const link = document.createElement('a');
const now = new Date();
const pad = value => String(value).padStart(2, '0');
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
link.download = button.dataset.name.replace('.png', `_${timestamp}.png`);
link.href = canvas.toDataURL('image/png');
link.click();
});
});
const compareSlider = document.getElementById('compareSlider');
const einkClip = document.getElementById('einkClip');
const compareHandle = document.getElementById('compareHandle');
compareSlider.addEventListener('input', () => {
einkClip.style.width = `${compareSlider.value}%`;
compareHandle.style.left = `${compareSlider.value}%`;
});
buildParamControls();
})();
</script>
</body>
</html>
#include <Arduino.h>
#include <SPI.h>
#include <SD.h>
#include <M5Unified.h>
#include <M5GFX.h>
namespace {
constexpr uint8_t kSdCsPin = 47;
constexpr uint8_t kSdSckPin = 15;
constexpr uint8_t kSdMosiPin = 13;
constexpr uint8_t kSdMisoPin = 14;
constexpr int kShtSdaPin = 3;
constexpr int kShtSclPin = 2;
constexpr uint32_t kSensorLogMs = 5000;
constexpr uint32_t kImageSwitchMs = 5UL * 60UL * 1000UL;
constexpr size_t kMaxImages = 100;
constexpr size_t kTrendSamples = 120;
constexpr float kSaturationBoost = 1.2f;
constexpr float kGamma = 0.85f;
constexpr float kErrorDiffusionStrength = 0.50f;
constexpr uint8_t kBayerMatrixSize = 4; // 2, 3, 4, or 8
constexpr int kBayerStrength = 50;
enum class Mode { Sensor,
Images };
M5Canvas canvas(&M5.Display);
Mode mode = Mode::Images;
String imagePaths[kMaxImages];
size_t imageCount = 0;
size_t imageIndex = 0;
bool sdReady = false;
bool shtReady = false;
float temperatureC = NAN;
float humidityPct = NAN;
uint32_t lastSensorUpdate = 0;
uint32_t lastSensorScreenUpdate = 0;
uint32_t lastImageSwitch = 0;
float temperatureHistory[kTrendSamples];
float humidityHistory[kTrendSamples];
size_t historyCount = 0;
size_t historyHead = 0;
bool isImageFile(const String& name) {
String lower = name;
lower.toLowerCase();
return lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg");
}
uint8_t sht40Crc8(const uint8_t* data, size_t length) {
uint8_t crc = 0xFF;
for (size_t i = 0; i < length; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : crc << 1;
}
}
return crc;
}
bool initSht40() {
return M5.In_I2C.isEnabled() && M5.In_I2C.scanID(0x44, 400000U);
}
bool readSht40(float& temperature, float& humidity) {
constexpr uint8_t kHighPrecisionCommand = 0xFD;
uint8_t data[6];
if (!M5.In_I2C.start(0x44, false, 400000U)) return false;
const bool commandWritten = M5.In_I2C.write(kHighPrecisionCommand);
const bool writeStopped = M5.In_I2C.stop();
if (!commandWritten || !writeStopped) return false;
delay(10);
if (!M5.In_I2C.start(0x44, true, 400000U)) return false;
const bool dataRead = M5.In_I2C.read(data, sizeof(data), true);
const bool readStopped = M5.In_I2C.stop();
if (!dataRead || !readStopped) return false;
if (data[2] != sht40Crc8(data, 2) || data[5] != sht40Crc8(data + 3, 2)) return false;
const uint16_t rawTemperature = (static_cast<uint16_t>(data[0]) << 8) | data[1];
const uint16_t rawHumidity = (static_cast<uint16_t>(data[3]) << 8) | data[4];
temperature = -45.0f + 175.0f * rawTemperature / 65535.0f;
humidity = -6.0f + 125.0f * rawHumidity / 65535.0f;
humidity = min(100.0f, max(0.0f, humidity));
return true;
}
void drawCentered(const char* message, uint32_t color = BLACK) {
canvas.fillSprite(WHITE);
canvas.setFont(&fonts::Font4);
canvas.setTextColor(color);
canvas.setTextDatum(middle_center);
canvas.drawString(message, canvas.width() / 2, canvas.height() / 2);
canvas.pushSprite(0, 0);
}
bool getImageSize(const String& path, int32_t& width, int32_t& height) {
File file = SD.open(path, FILE_READ);
if (!file) return false;
uint8_t header[24] = {};
const size_t bytesRead = file.read(header, sizeof(header));
if (bytesRead >= 24 && !memcmp(header, "\x89PNG\r\n\x1a\n", 8)) {
width = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19];
height = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23];
file.close();
return width > 0 && height > 0;
}
if (bytesRead < 2 || header[0] != 0xFF || header[1] != 0xD8) {
file.close();
return false;
}
file.seek(2);
while (file.available()) {
if (file.read() != 0xFF) continue;
int marker;
do { marker = file.read(); } while (marker == 0xFF && file.available());
if (marker < 0) break;
if (marker == 0xD8 || marker == 0xD9) continue;
const int lengthHi = file.read();
const int lengthLo = file.read();
const int length = (lengthHi << 8) | lengthLo;
if (length < 2) break;
const bool isSof = (marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) || (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF);
if (isSof && length >= 7) {
file.read(); // Precision.
height = (file.read() << 8) | file.read();
width = (file.read() << 8) | file.read();
file.close();
return width > 0 && height > 0;
}
file.seek(file.position() + length - 2);
}
file.close();
return false;
}
void drawColorBars() {
constexpr uint16_t colors[] = { YELLOW, RED, GREEN, BLUE, BLACK, WHITE };
const int barWidth = canvas.width() / 6;
for (int i = 0; i < 6; ++i) {
canvas.fillRect(i * barWidth, 0, i == 5 ? canvas.width() - i * barWidth : barWidth,
canvas.height(), colors[i]);
}
canvas.pushSprite(0, 0);
}
void scanImages() {
imageCount = 0;
File root = SD.open("/");
if (!root) return;
for (File entry = root.openNextFile(); entry && imageCount < kMaxImages;
entry = root.openNextFile()) {
if (!entry.isDirectory() && isImageFile(String(entry.name()))) {
String path = String(entry.name());
if (!path.startsWith("/")) path = "/" + path;
imagePaths[imageCount++] = path;
}
entry.close();
}
root.close();
}
bool initSd() {
SPI.begin(kSdSckPin, kSdMisoPin, kSdMosiPin, kSdCsPin);
if (!SD.begin(kSdCsPin, SPI, 25000000)) {
Serial.println("SD initialization failed");
return false;
}
scanImages();
Serial.printf("Found %u image(s)\n", static_cast<unsigned>(imageCount));
return true;
}
void calcHistoryMinMax(const float* history, float& outMin, float& outMax) {
const size_t firstIndex = (historyHead + kTrendSamples - historyCount) % kTrendSamples;
outMin = outMax = history[firstIndex];
for (size_t i = 1; i < historyCount; ++i) {
const size_t index = (historyHead + kTrendSamples - historyCount + i) % kTrendSamples;
outMin = min(outMin, history[index]);
outMax = max(outMax, history[index]);
}
if (outMin == outMax) {
outMin -= 0.5f;
outMax += 0.5f;
}
}
void drawFittedText(const char* text, int cx, int cy, int maxW, int maxH, uint16_t color) {
canvas.setFont(&fonts::FreeMonoBold24pt7b);
canvas.setTextSize(1);
const int baseW = canvas.textWidth(text);
const int baseH = canvas.fontHeight();
float scale = min(static_cast<float>(maxW) / baseW, static_cast<float>(maxH) / baseH);
if (scale < 0.1f) scale = 0.1f;
canvas.setTextSize(scale);
canvas.setTextDatum(middle_center);
canvas.setTextColor(color);
canvas.drawString(text, cx, cy);
canvas.setTextSize(1);
}
void drawTrendPanel(int x, int y, int w, int h, const float* history, float latestValue,
const char* unitLabel, uint16_t barColor) {
canvas.fillRect(x, y, w, h, WHITE);
constexpr int kBarPixelW = 4;
const int graphAreaW = static_cast<int>(kTrendSamples) * kBarPixelW;
const int labelAreaX = x + graphAreaW;
const int labelAreaW = w - graphAreaW;
if (isnan(latestValue) || historyCount == 0) {
canvas.setTextDatum(middle_center);
canvas.setFont(&fonts::Font2);
canvas.setTextColor(RED);
canvas.drawString("no data", x + w / 2, y + h / 2);
return;
}
float valueMin, valueMax;
calcHistoryMinMax(history, valueMin, valueMax);
for (size_t age = 0; age < historyCount; ++age) {
// age 0 = newest (leftmost); age increases toward the oldest sample.
const size_t index = (historyHead + kTrendSamples - 1 - age) % kTrendSamples;
const float value = history[index];
const float normalized = (value - valueMin) / (valueMax - valueMin);
int barH = static_cast<int>(normalized * h + 0.5f);
if (barH < 1) barH = 1;
if (barH > h) barH = h;
const int barX = x + static_cast<int>(age) * kBarPixelW;
canvas.fillRect(barX, y + h - barH, kBarPixelW, barH, barColor);
}
canvas.fillRect(labelAreaX, y, labelAreaW, h, WHITE);
constexpr int kLabelBorderPx = 4;
const int colorX = labelAreaX + kLabelBorderPx;
const int colorY = y + kLabelBorderPx;
const int colorW = labelAreaW - kLabelBorderPx * 2;
const int colorH = h - kLabelBorderPx * 2;
//canvas.fillRect(colorX, colorY, colorW, colorH, barColor);
char numLabel[16];
snprintf(numLabel, sizeof(numLabel), "%.1f", latestValue);
const int labelCenterX = colorX + colorW / 2;
const int padding = 6;
drawFittedText(numLabel, labelCenterX, colorY + colorH / 4, colorW - padding * 2, colorH / 2 - padding, barColor);
drawFittedText(unitLabel, labelCenterX, colorY + 3 * colorH / 4, colorW - padding * 2, colorH / 2 - padding, barColor);
}
void renderSensor() {
canvas.fillSprite(WHITE);
// No title/header text: the whole screen is split top/bottom between the
// two panels, each self-labeled with its latest reading.
const int panelW = canvas.width();
const int panelH = canvas.height() / 2;
drawTrendPanel(0, 0, panelW, panelH, temperatureHistory, temperatureC, "C", RED);
drawTrendPanel(0, panelH, panelW, canvas.height() - panelH, humidityHistory, humidityPct, "%", BLUE);
canvas.pushSprite(0, 0);
lastSensorScreenUpdate = millis();
}
struct RGBColor {
uint8_t r, g, b;
uint16_t color16;
};
constexpr RGBColor kPalette[] = {
{ 255, 255, 255, WHITE },
{ 0, 0, 0, BLACK },
{ 255, 0, 0, RED },
{ 0, 255, 0, GREEN },
{ 0, 0, 255, BLUE },
{ 255, 255, 0, YELLOW }
};
constexpr size_t kPaletteSize = sizeof(kPalette) / sizeof(kPalette[0]);
long weightedRgbDistance(int r1, int g1, int b1, int r2, int g2, int b2) {
const long dr = r1 - r2;
const long dg = g1 - g2;
const long db = b1 - b2;
return 2 * dr * dr + 4 * dg * dg + 3 * db * db;
}
size_t findClosestColorIndex(int r, int g, int b) {
size_t closestIdx = 0;
long minDist = 0x7FFFFFFF;
for (size_t i = 0; i < kPaletteSize; ++i) {
const long dist = weightedRgbDistance(r, g, b, kPalette[i].r, kPalette[i].g, kPalette[i].b);
if (dist < minDist) {
minDist = dist;
closestIdx = i;
}
}
return closestIdx;
}
void rgbToHsv(float r, float g, float b, float& h, float& s, float& v) {
float minVal = min(min(r, g), b);
float maxVal = max(max(r, g), b);
float delta = maxVal - minVal;
v = maxVal / 255.0f;
if (maxVal < 1e-5f || delta < 1e-5f) {
s = 0.0f;
h = 0.0f;
return;
}
s = delta / maxVal;
if (r >= maxVal) {
h = (g - b) / delta;
} else if (g >= maxVal) {
h = 2.0f + (b - r) / delta;
} else {
h = 4.0f + (r - g) / delta;
}
h *= 60.0f;
if (h < 0.0f) h += 360.0f;
}
void hsvToRgb(float h, float s, float v, float& r, float& g, float& b) {
if (s <= 0.0f) {
r = g = b = v * 255.0f;
return;
}
float hh = h;
if (hh >= 360.0f) hh = 0.0f;
hh /= 60.0f;
int i = static_cast<int>(hh);
float ff = hh - i;
float p = v * (1.0f - s);
float q = v * (1.0f - (s * ff));
float t = v * (1.0f - (s * (1.0f - ff)));
switch (i) {
case 0:
r = v;
g = t;
b = p;
break;
case 1:
r = q;
g = v;
b = p;
break;
case 2:
r = p;
g = v;
b = t;
break;
case 3:
r = p;
g = q;
b = v;
break;
case 4:
r = t;
g = p;
b = v;
break;
default:
r = v;
g = p;
b = q;
break;
}
r *= 255.0f;
g *= 255.0f;
b *= 255.0f;
}
void applyFloydSteinbergDither(M5Canvas& targetCanvas) {
const int w = targetCanvas.width();
const int h = targetCanvas.height();
struct PixelBuffer {
float r, g, b;
};
PixelBuffer* buf = new PixelBuffer[w * h];
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
uint16_t rgb565 = targetCanvas.readPixel(x, y);
size_t idx = y * w + x;
float r = static_cast<float>((rgb565 >> 11) & 0x1F) * 255.0f / 31.0f;
float g = static_cast<float>((rgb565 >> 5) & 0x3F) * 255.0f / 63.0f;
float b = static_cast<float>(rgb565 & 0x1F) * 255.0f / 31.0f;
r = 255.0f * pow(r / 255.0f, kGamma);
g = 255.0f * pow(g / 255.0f, kGamma);
b = 255.0f * pow(b / 255.0f, kGamma);
float hue, sat, val;
rgbToHsv(r, g, b, hue, sat, val);
sat = min(1.0f, sat * kSaturationBoost);
hsvToRgb(hue, sat, val, r, g, b);
buf[idx].r = r;
buf[idx].g = g;
buf[idx].b = b;
}
}
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
size_t idx = y * w + x;
int curR = constrain(static_cast<int>(buf[idx].r + 0.5f), 0, 255);
int curG = constrain(static_cast<int>(buf[idx].g + 0.5f), 0, 255);
int curB = constrain(static_cast<int>(buf[idx].b + 0.5f), 0, 255);
size_t palIdx = findClosestColorIndex(curR, curG, curB);
RGBColor nearest = kPalette[palIdx];
targetCanvas.drawPixel(x, y, nearest.color16);
float errR = static_cast<float>(curR - nearest.r);
float errG = static_cast<float>(curG - nearest.g);
float errB = static_cast<float>(curB - nearest.b);
auto addError = [&](int nx, int ny, float weight) {
if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
size_t nIdx = ny * w + nx;
buf[nIdx].r += errR * weight * kErrorDiffusionStrength;
buf[nIdx].g += errG * weight * kErrorDiffusionStrength;
buf[nIdx].b += errB * weight * kErrorDiffusionStrength;
}
};
addError(x + 1, y, 7.0f / 16.0f);
addError(x - 1, y + 1, 3.0f / 16.0f);
addError(x, y + 1, 5.0f / 16.0f);
addError(x + 1, y + 1, 1.0f / 16.0f);
}
}
delete[] buf;
}
void applyAtkinsonDither(M5Canvas& targetCanvas) {
const int w = targetCanvas.width();
const int h = targetCanvas.height();
struct PixelBuffer {
float r, g, b;
};
PixelBuffer* buf = new PixelBuffer[w * h];
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
uint16_t rgb565 = targetCanvas.readPixel(x, y);
size_t idx = y * w + x;
float r = static_cast<float>((rgb565 >> 11) & 0x1F) * 255.0f / 31.0f;
float g = static_cast<float>((rgb565 >> 5) & 0x3F) * 255.0f / 63.0f;
float b = static_cast<float>(rgb565 & 0x1F) * 255.0f / 31.0f;
r = 255.0f * pow(r / 255.0f, kGamma);
g = 255.0f * pow(g / 255.0f, kGamma);
b = 255.0f * pow(b / 255.0f, kGamma);
float hue, sat, val;
rgbToHsv(r, g, b, hue, sat, val);
sat = min(1.0f, sat * kSaturationBoost);
hsvToRgb(hue, sat, val, r, g, b);
buf[idx].r = r;
buf[idx].g = g;
buf[idx].b = b;
}
}
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
size_t idx = y * w + x;
int curR = constrain(static_cast<int>(buf[idx].r + 0.5f), 0, 255);
int curG = constrain(static_cast<int>(buf[idx].g + 0.5f), 0, 255);
int curB = constrain(static_cast<int>(buf[idx].b + 0.5f), 0, 255);
size_t palIdx = findClosestColorIndex(curR, curG, curB);
RGBColor nearest = kPalette[palIdx];
targetCanvas.drawPixel(x, y, nearest.color16);
float errR = static_cast<float>(curR - nearest.r) * kErrorDiffusionStrength / 8.0f;
float errG = static_cast<float>(curG - nearest.g) * kErrorDiffusionStrength / 8.0f;
float errB = static_cast<float>(curB - nearest.b) * kErrorDiffusionStrength / 8.0f;
auto addError = [&](int nx, int ny) {
if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
size_t nIdx = ny * w + nx;
buf[nIdx].r += errR;
buf[nIdx].g += errG;
buf[nIdx].b += errB;
}
};
addError(x + 1, y);
addError(x + 2, y);
addError(x - 1, y + 1);
addError(x, y + 1);
addError(x + 1, y + 1);
addError(x, y + 2);
}
}
delete[] buf;
}
constexpr uint8_t kBayer2x2[2][2] = {
{ 0, 2 },
{ 3, 1 },
};
constexpr uint8_t kBayer3x3[3][3] = {
{ 0, 7, 3 },
{ 6, 5, 2 },
{ 4, 1, 8 },
};
constexpr uint8_t kBayer4x4[4][4] = {
{ 0, 8, 2, 10 },
{ 12, 4, 14, 6 },
{ 3, 11, 1, 9 },
{ 15, 7, 13, 5 },
};
constexpr uint8_t kBayer8x8[8][8] = {
{ 0, 32, 8, 40, 2, 34, 10, 42 },
{ 48, 16, 56, 24, 50, 18, 58, 26 },
{ 12, 44, 4, 36, 14, 46, 6, 38 },
{ 60, 28, 52, 20, 62, 30, 54, 22 },
{ 3, 35, 11, 43, 1, 33, 9, 41 },
{ 51, 19, 59, 27, 49, 17, 57, 25 },
{ 15, 47, 7, 39, 13, 45, 5, 37 },
{ 63, 31, 55, 23, 61, 29, 53, 21 },
};
uint8_t bayerValue(uint8_t matrixSize, int x, int y) {
switch (matrixSize) {
case 2: return kBayer2x2[y % 2][x % 2];
case 3: return kBayer3x3[y % 3][x % 3];
case 8: return kBayer8x8[y % 8][x % 8];
default: return kBayer4x4[y % 4][x % 4];
}
}
uint8_t ditherChannel(uint8_t channel, uint8_t matrixValue, uint8_t matrixSize, int strength) {
const int matrixArea = matrixSize * matrixSize;
const int bias = (matrixValue * strength * 2) / matrixArea - strength;
const int biased = constrain(static_cast<int>(channel) + bias, 0, 255);
const int level = (biased * 7 + 127) / 255;
return static_cast<uint8_t>(level * 255 / 7);
}
void applyBayerDither(M5Canvas& targetCanvas) {
const int w = targetCanvas.width();
const int h = targetCanvas.height();
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
const uint16_t rgb565 = targetCanvas.readPixel(x, y);
float r = static_cast<float>((rgb565 >> 11) & 0x1F) * 255.0f / 31.0f;
float g = static_cast<float>((rgb565 >> 5) & 0x3F) * 255.0f / 63.0f;
float b = static_cast<float>(rgb565 & 0x1F) * 255.0f / 31.0f;
r = 255.0f * pow(r / 255.0f, kGamma);
g = 255.0f * pow(g / 255.0f, kGamma);
b = 255.0f * pow(b / 255.0f, kGamma);
float hue, saturation, value;
rgbToHsv(r, g, b, hue, saturation, value);
saturation = min(1.0f, saturation * kSaturationBoost);
hsvToRgb(hue, saturation, value, r, g, b);
const uint8_t matrix = bayerValue(kBayerMatrixSize, x, y);
const uint8_t ditheredR = ditherChannel(constrain(static_cast<int>(r + 0.5f), 0, 255), matrix,
kBayerMatrixSize, kBayerStrength);
const uint8_t ditheredG = ditherChannel(constrain(static_cast<int>(g + 0.5f), 0, 255), matrix,
kBayerMatrixSize, kBayerStrength);
const uint8_t ditheredB = ditherChannel(constrain(static_cast<int>(b + 0.5f), 0, 255), matrix,
kBayerMatrixSize, kBayerStrength);
const size_t paletteIndex = findClosestColorIndex(ditheredR, ditheredG, ditheredB);
targetCanvas.drawPixel(x, y, kPalette[paletteIndex].color16);
}
}
}
void renderImage() {
if (!sdReady) {
drawColorBars();
return;
}
if (imageCount == 0) {
drawColorBars();
return;
}
canvas.fillSprite(WHITE);
const String& path = imagePaths[imageIndex];
int32_t imageWidth = 0;
int32_t imageHeight = 0;
if (!getImageSize(path, imageWidth, imageHeight)) {
drawCentered("Image size read failed", RED);
return;
}
const float scale = min(static_cast<float>(canvas.width()) / imageWidth,
static_cast<float>(canvas.height()) / imageHeight);
const int x = (canvas.width() - imageWidth * scale) / 2;
const int y = (canvas.height() - imageHeight * scale) / 2;
bool drawn = path.endsWith(".png") || path.endsWith(".PNG")
? canvas.drawPngFile(SD, path.c_str(), x, y, 0, 0, 0, 0, scale, scale)
: canvas.drawJpgFile(SD, path.c_str(), x, y, 0, 0, 0, 0, scale, scale);
if (!drawn) {
drawCentered("Image load failed", RED);
Serial.printf("Image load failed: %s\n", path.c_str());
return;
}
applyBayerDither(canvas);
canvas.pushSprite(0, 0);
lastImageSwitch = millis();
Serial.printf("Displayed: %s\n", path.c_str());
}
void logSensorSample() {
lastSensorUpdate = millis();
bool sampleOk = shtReady && readSht40(temperatureC, humidityPct);
if (!sampleOk) {
// Re-open the I2C bus once so a transient bus error recovers automatically.
shtReady = initSht40();
sampleOk = shtReady && readSht40(temperatureC, humidityPct);
}
if (sampleOk) {
Serial.printf("SHT40: %.2f C, %.2f %%RH\n", temperatureC, humidityPct);
temperatureHistory[historyHead] = temperatureC;
humidityHistory[historyHead] = humidityPct;
historyHead = (historyHead + 1) % kTrendSamples;
if (historyCount < kTrendSamples) ++historyCount;
if (sdReady) {
const bool newFile = !SD.exists("/sht40.csv");
File logFile = SD.open("/sht40.csv", FILE_APPEND);
if (logFile) {
if (newFile) logFile.println("uptime_s,temperature_c,humidity_pct");
logFile.printf("%lu,%.2f,%.2f\n", millis() / 1000UL, temperatureC, humidityPct);
logFile.close();
}
}
} else {
temperatureC = NAN;
humidityPct = NAN;
Serial.println("SHT40 read error");
}
}
void changeImage(int direction) {
if (imageCount == 0) return;
imageIndex = direction > 0 ? (imageIndex + 1) % imageCount
: (imageIndex + imageCount - 1) % imageCount;
renderImage();
}
} // namespace
void setup() {
auto cfg = M5.config();
cfg.clear_display = false;
M5.begin(cfg);
Serial.begin(115200);
M5.Display.setRotation(3); // 600 x 400 landscape
M5.Display.setEpdMode(epd_mode_t::epd_fastest);
canvas.createSprite(M5.Display.width(), M5.Display.height());
shtReady = initSht40();
if (!shtReady) Serial.println("SHT40 not found");
sdReady = initSd();
logSensorSample();
renderImage();
}
void loop() {
M5.update();
if (M5.BtnC.wasClicked()) {
mode = mode == Mode::Sensor ? Mode::Images : Mode::Sensor;
if (mode == Mode::Sensor) {
Serial.println("Mode: SHT40 sensor");
logSensorSample();
renderSensor();
} else {
Serial.println("Mode: image viewer");
renderImage();
}
}
if (mode == Mode::Images) {
if (M5.BtnA.wasClicked()) changeImage(-1);
if (M5.BtnB.wasClicked()) changeImage(1);
}
if (millis() - lastSensorUpdate >= kSensorLogMs) {
logSensorSample();
}
if (mode == Mode::Sensor && millis() - lastSensorScreenUpdate >= kImageSwitchMs) {
renderSensor();
}
if (mode == Mode::Images && imageCount > 1 && millis() - lastImageSwitch >= kImageSwitchMs) {
changeImage(1);
}
delay(20);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment