Last active
August 18, 2026 18:53
-
-
Save Yuikawa-Akira/91901d3ba193a8f4347e1fc6ffd24f11 to your computer and use it in GitHub Desktop.
PaperColor_SHT40_SD_Viewer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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