From 28f8667baaa2d7ec3d7c321cd6159299903d747f Mon Sep 17 00:00:00 2001 From: Nic Limper Date: Tue, 16 May 2023 15:34:51 +0200 Subject: [PATCH] Painter - new paint option for freehand drawing and quick texts - added gamma correction on rendering - option to turn dither on/off on jpg upload - python version for packagebinaries (same output as the php version) --- ESP32_AP-Flasher/data/www/index.html | 1 + ESP32_AP-Flasher/data/www/main.css | 84 +++++- ESP32_AP-Flasher/data/www/main.js | 38 +++ ESP32_AP-Flasher/data/www/painter.js | 360 ++++++++++++++++++++++++ ESP32_AP-Flasher/src/contentmanager.cpp | 2 +- ESP32_AP-Flasher/src/makeimage.cpp | 27 +- ESP32_AP-Flasher/src/web.cpp | 6 +- zbs243_AP_FW/packagebinaries.py | 57 ++++ 8 files changed, 567 insertions(+), 8 deletions(-) create mode 100644 ESP32_AP-Flasher/data/www/painter.js create mode 100644 zbs243_AP_FW/packagebinaries.py diff --git a/ESP32_AP-Flasher/data/www/index.html b/ESP32_AP-Flasher/data/www/index.html index 9fb22b36..b6f07e69 100644 --- a/ESP32_AP-Flasher/data/www/index.html +++ b/ESP32_AP-Flasher/data/www/index.html @@ -38,6 +38,7 @@ +

diff --git a/ESP32_AP-Flasher/data/www/main.css b/ESP32_AP-Flasher/data/www/main.css index be547bdc..c0459d9f 100644 --- a/ESP32_AP-Flasher/data/www/main.css +++ b/ESP32_AP-Flasher/data/www/main.css @@ -92,14 +92,17 @@ input { border-radius: 0px; } -input[type=button] { +input[type=button], button { border: 0px; padding: 4px 10px; cursor:pointer; } -input[type=button]:hover { + +input[type=button]:hover, +button:hover { background-color:#aaaaaa; } + select { padding: 4px; border-radius: 0px; @@ -128,7 +131,7 @@ select { #configbox input, #apconfigbox input { border: solid 1px #666666; - padding: 4px; + /*padding: 4px;*/ } #configbox label, #apconfigbox label { @@ -326,6 +329,81 @@ ul.messages li.new { color: red; } +#paintbutton { + padding: 1px 3px; + border: 1px solid black; + font-size: 1.3em; + vertical-align: top; + margin-left:12px; + cursor: pointer; +} + +#paintbutton:hover { + background-color: #aaaaaa; +} + +/* painter */ + +#canvasdiv { + padding: 5px; +} + +#canvasdiv canvas { + border: 1px solid black; +} + +#buttonbar { + padding: 5px; + display: flex; + gap: 5px; +} + +#buttonbar button, +#layersdiv button { + padding: 1px 2px; + border: 1px solid #cccccc; + background-color: #dddddd; + width: 40px; +} +#buttonbar button { + font-size: 1.2em; + font-weight: bold; +} + +#buttonbar .active { + background-color: #ffffff; + cursor: pointer; +} + +#buttonbar button:hover, +#layersdiv button:hover { + background-color: #cccccc; + cursor: pointer; +} + +#layersdiv { + padding: 0px 5px; +} + +#layersdiv>div { + display: flex; + gap: 5px; + margin-bottom: 5px; +} + +#layersdiv input, +#layersdiv select { + padding: 2px; +} + +#font-select { + width: 150px; +} + +#savebar button { + border: solid 1px #666666; +} + @media(max-width: 460px) { .messages li div, ul.messages li div.date, ul.messages li div.message { diff --git a/ESP32_AP-Flasher/data/www/main.js b/ESP32_AP-Flasher/data/www/main.js index 3b7dbd6f..9049b2ab 100644 --- a/ESP32_AP-Flasher/data/www/main.js +++ b/ESP32_AP-Flasher/data/www/main.js @@ -31,6 +31,7 @@ contentModeOptions[12] = []; const imageQueue = []; let isProcessing = false; let servertimediff = 0; +let paintLoaded = false, paintShow = false; let socket; connect(); @@ -343,6 +344,40 @@ $('#apcfgsave').onclick = function () { $('#apconfigbox').style.display = 'none'; } +$('#paintbutton').onclick = function () { + if (paintShow) { + paintShow = false; + $('#cfgsave').parentNode.style.display = 'block'; + contentselected(); + } else { + paintShow = true; + $('#cfgsave').parentNode.style.display = 'none'; + $('#customoptions').innerHTML = "

"; + const mac = $('#cfgmac').dataset.mac + const hwtype = $('#tag' + mac).dataset.hwtype; + var [width, height] = displaySizeLookup[hwtype] || [0, 0]; + if (height > width) [width, height] = [height, width]; + if (paintLoaded) { + startPainter(mac, width, height); + } else { + loadScript('painter.js', function () { + startPainter(mac, width, height); + }); + } + } +} + +function loadScript(url, callback) { + var script = document.createElement('script'); + script.src = url; + script.onload = function () { + if (callback) { + callback(); + } + }; + document.head.appendChild(script); +} + function contentselected() { let contentMode = $('#cfgcontent').value; let extraoptions = contentModeOptions[contentMode]; @@ -352,6 +387,7 @@ function contentselected() { obj = JSON.parse($('#cfgcontent').dataset.json); } if (contentMode) { + $('#paintbutton').style.display = (contentMode == 0 ? 'inline-block' : 'none'); extraoptions.forEach(element => { var label = document.createElement("label"); label.innerHTML = element; @@ -366,6 +402,8 @@ function contentselected() { $('#customoptions').appendChild(p); }); } + paintShow = false; + $('#cfgsave').parentNode.style.display = 'block'; } function showMessage(message,iserr) { diff --git a/ESP32_AP-Flasher/data/www/painter.js b/ESP32_AP-Flasher/data/www/painter.js new file mode 100644 index 00000000..1244dff2 --- /dev/null +++ b/ESP32_AP-Flasher/data/www/painter.js @@ -0,0 +1,360 @@ +function startPainter(mac, width, height) { + let isDrawing = false; + let lastX = 0; + let lastY = 0; + let color = 'black'; + let linewidth = 3; + let cursor = 'auto'; + let isAddingText = false; + let layerDiv, intervalId, showCursor, input, textX, textY, font, sizeSelect, isDragging; + + var fonts = ['Roboto', 'Open Sans', 'Lato', 'Montserrat', 'PT Sans', 'Barlow Condensed', 'Headland One', 'Sofia Sans Extra Condensed', 'Mynerve', 'Lilita One', 'Passion One', 'Big Shoulders Display']; + + loadGoogleFonts(fonts); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.imageSmoothingEnabled = false; + + $("#canvasdiv").appendChild(canvas); + canvas.style.imageRendering = 'pixelated'; + + canvas.addEventListener('mousedown', startDrawing); + canvas.addEventListener('mouseup', stopDrawing); + canvas.addEventListener('mousemove', draw); + + canvas.addEventListener('touchstart', startDrawing, { passive: true }); + canvas.addEventListener('touchend', stopDrawing); + canvas.addEventListener('touchmove', draw, { passive: true }); + + const bgCanvas = document.createElement('canvas'); + bgCanvas.width = canvas.width; + bgCanvas.height = canvas.height; + const bgCtx = bgCanvas.getContext('2d'); + + bgCtx.fillStyle = '#ffffff'; + bgCtx.fillRect(0, 0, bgCanvas.width, bgCanvas.height); + + const txtButton = document.createElement('button'); + txtButton.innerHTML = 'tT'; + txtButton.style.fontStyle = 'italic'; + txtButton.addEventListener('click', addText); + + const blackButton = document.createElement('button'); + blackButton.innerHTML = '﹏🖌'; + blackButton.style.color = 'black'; + blackButton.addEventListener('click', () => { + color = 'black'; + linewidth = 3; + cursor = 'url("data:image/svg+xml;utf8,") 2 2, auto'; + blackButton.classList.add('active'); + redButton.classList.remove('active'); + whiteButton.classList.remove('active'); + }); + blackButton.classList.add('active'); + + const redButton = document.createElement('button'); + redButton.innerHTML = '﹏🖌'; + redButton.style.color = 'red'; + redButton.addEventListener('click', () => { + color = 'red'; + linewidth = 3; + cursor = 'url("data:image/svg+xml;utf8,") 2 2, auto'; + blackButton.classList.remove('active'); + redButton.classList.add('active'); + whiteButton.classList.remove('active'); + }); + + const whiteButton = document.createElement('button'); + whiteButton.innerHTML = '⬤'; + whiteButton.style.color = 'white'; + whiteButton.addEventListener('click', () => { + color = 'white'; + linewidth = 20; + cursor = 'url("data:image/svg+xml;utf8,") 10 10, auto'; + blackButton.classList.remove('active'); + redButton.classList.remove('active'); + whiteButton.classList.add('active'); + }); + + const clearButton = document.createElement('button'); + clearButton.innerHTML = '🖵'; + clearButton.addEventListener('click', () => { + if (isAddingText) handleFinish(false); + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }); + + const uploadButton = document.createElement('button'); + uploadButton.innerHTML = 'Upload'; + uploadButton.addEventListener('click', () => { + if (isAddingText) handleFinish(true); + const dataURL = canvas.toDataURL('image/jpeg'); + const binaryImage = dataURLToBlob(dataURL); + const formData = new FormData(); + formData.append('mac', mac); + formData.append('dither', '0'); + formData.append('file', binaryImage, 'image.jpg'); + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/imgupload'); + xhr.send(formData); + $('#configbox').style.display = 'none'; + }); + + $("#buttonbar").appendChild(blackButton); + $("#buttonbar").appendChild(redButton); + $("#buttonbar").appendChild(whiteButton); + $("#buttonbar").appendChild(txtButton); + $("#buttonbar").appendChild(clearButton); + $("#savebar").appendChild(uploadButton); + + canvas.addEventListener('mouseenter', function () { + if (!isAddingText) { + canvas.style.cursor = cursor; + } else { + canvas.style.cursor = 'move'; + } + }); + + canvas.addEventListener('mouseleave', function () { + canvas.style.cursor = 'auto'; + }); + + function startDrawing(e) { + if (isAddingText) return; + isDrawing = true; + var rect = canvas.getBoundingClientRect(); + lastX = e.pageX - rect.left - window.pageXOffset; + lastY = e.pageY - rect.top - window.pageYOffset; + } + + function stopDrawing() { + if (isAddingText) return; + isDrawing = false; + } + + function draw(e) { + if (isAddingText) return; + if (!isDrawing) return; + var rect = canvas.getBoundingClientRect(); + ctx.beginPath(); + ctx.moveTo(lastX, lastY); + ctx.lineTo(e.pageX - rect.left - window.pageXOffset, e.pageY - rect.top - window.pageYOffset); + ctx.strokeStyle = color; + ctx.lineWidth = linewidth; + ctx.lineCap = "round"; + ctx.stroke(); + lastX = e.pageX - rect.left - window.pageXOffset; + lastY = e.pageY - rect.top - window.pageYOffset; + } + + function addText() { + if (isAddingText) { + handleFinish(true); + return; + } + txtButton.classList.add('active'); + bgCtx.drawImage(canvas, 0, 0); + + const defaultX = 5; + const defaultY = 40; + isDragging = false; + let startX, startY; + showCursor = true; + + textX = defaultX; + textY = defaultY; + font = '24px ' + fonts[0]; + + input = document.createElement('textarea'); + input.type = 'text'; + input.placeholder = 'Type text here'; + input.style.opacity = '0'; + input.style.position = 'absolute'; + input.style.left = '-200px' + + input.addEventListener('input', () => { + drawText(input.value, textX, textY); + }); + input.addEventListener('keyup', () => { + input.selectionStart = input.selectionEnd = input.value.length; + }); + + input.addEventListener('blur', function () { + input.focus(); + }); + + intervalId = setInterval(function () { + showCursor = !showCursor; + drawText(input.value, textX, textY); + }, 300); + + canvas.addEventListener('mouseup', handleMouseUp); + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mousemove', handleMouseMove); + + canvas.addEventListener('touchstart', handleTouchStart, { passive: true }); + canvas.addEventListener('touchend', handleTouchEnd); + canvas.addEventListener('touchmove', handleTouchMove, { passive: true }); + + var sizes = [10,11,12,13,14,16,18,20,24,28,32,36,40,48,56,64,72,84]; + + const fontSelect = document.createElement('select'); + fontSelect.id = 'font-select'; + for (var i = 0; i < fonts.length; i++) { + const option = document.createElement('option'); + option.value = fonts[i]; + option.text = fonts[i]; + option.style.fontFamily = fonts[i]; + fontSelect.appendChild(option); + } + + sizeSelect = document.createElement('select'); + sizeSelect.id = 'size-select'; + for (var i = 0; i < sizes.length; i++) { + const option = document.createElement('option'); + option.value = sizes[i]; + option.text = sizes[i] + ' px'; + sizeSelect.appendChild(option); + } + + function updateFont() { + var selectedFont = fontSelect.value; + var selectedSize = sizeSelect.value; + fontSelect.style.fontFamily = selectedFont; + font = selectedSize + 'px ' + selectedFont; + drawText(input.value, textX, textY); + } + + fontSelect.value = fonts[0]; + sizeSelect.value = '24'; + fontSelect.addEventListener('change', updateFont); + sizeSelect.addEventListener('change', updateFont); + + const finishButton = document.createElement('button'); + finishButton.innerHTML = '✔'; + finishButton.addEventListener('click', clickHandleFinish); + + layerDiv = document.createElement('div'); + + layerDiv.appendChild(input); + layerDiv.appendChild(fontSelect); + layerDiv.appendChild(sizeSelect); + layerDiv.appendChild(finishButton); + $("#layersdiv").appendChild(layerDiv); + input.focus(); + + isAddingText = true; + //cursor = 'move'; + blackButton.innerHTML = 'aA' + redButton.innerHTML = 'aA' + if (color=='white') { + whiteButton.classList.remove('active'); + blackButton.classList.add('active'); + color='black'; + } + } + + function handleFinish(apply) { + canvas.removeEventListener('mousedown', handleMouseDown); + canvas.removeEventListener('mouseup', handleMouseUp); + canvas.removeEventListener('mousemove', handleMouseMove); + + canvas.removeEventListener('touchstart', handleTouchStart); + canvas.removeEventListener('touchend', handleTouchEnd); + canvas.removeEventListener('touchmove', handleTouchMove); + isAddingText = false; + cursor = 'auto'; + layerDiv.remove(); + clearInterval(intervalId); + showCursor = false; + if (apply) drawText(input.value, textX, textY); + txtButton.classList.remove('active'); + blackButton.innerHTML = '﹏🖌' + redButton.innerHTML = '﹏🖌' + } + + function drawText(text, x, y) { + ctx.drawImage(bgCanvas, 0, 0); + ctx.save(); + ctx.translate(x, y); + ctx.font = font; + ctx.fillStyle = color; + const lines = text.split('\n'); + lines.forEach((line, index) => { + ctx.fillText(line + (showCursor && index === lines.length - 1 ? '|' : ''), 0, index * (sizeSelect.value * 1.1)); + }); + ctx.restore(); + } + + function handleMouseDown(e) { + isDragging = true; + startX = textX; + startY = textY; + ({ clientX: lastMouseX, clientY: lastMouseY } = e); + } + + function handleMouseMove(e) { + if (isDragging) { + const { clientX, clientY } = e; + textX = startX + clientX - lastMouseX; + textY = startY + clientY - lastMouseY; + drawText(input.value, textX, textY); + } + } + + function handleTouchStart(e) { + isDragging = true; + startX = textX; + startY = textY; + ({ clientX: lastTouchX, clientY: lastTouchY } = e.touches[0]); + } + + function handleTouchMove(e) { + if (isDragging) { + const { clientX, clientY } = e.touches[0]; + textX = startX + clientX - lastTouchX; + textY = startY + clientY - lastTouchY; + drawText(input.value, textX, textY); + } + } + + function handleMouseUp(e) { + isDragging = false; + } + + function handleTouchEnd(e) { + isDragging = false; + } + + function clickHandleFinish() { + handleFinish(true); + } + +} + +function loadGoogleFonts(fonts) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://fonts.googleapis.com/css?family=' + fonts.join('|'); + document.head.appendChild(link); +} + +function dataURLToBlob(dataURL) { + const byteString = atob(dataURL.split(',')[1]); + const mimeString = dataURL.split(',')[0].split(':')[1].split(';')[0]; + const arrayBuffer = new ArrayBuffer(byteString.length); + const uint8Array = new Uint8Array(arrayBuffer); + for (let i = 0; i < byteString.length; i++) { + uint8Array[i] = byteString.charCodeAt(i); + } + return new Blob([arrayBuffer], { type: mimeString }); +} + +paintLoaded = true; diff --git a/ESP32_AP-Flasher/src/contentmanager.cpp b/ESP32_AP-Flasher/src/contentmanager.cpp index a41e6b1a..3fc16642 100644 --- a/ESP32_AP-Flasher/src/contentmanager.cpp +++ b/ESP32_AP-Flasher/src/contentmanager.cpp @@ -107,7 +107,7 @@ void drawNew(uint8_t mac[8], bool buttonPressed, tagRecord *&taginfo) { case Image: if (cfgobj["filename"].as() && cfgobj["filename"].as() != "null" && !cfgobj["#fetched"].as()) { - if (cfgobj["dither"] && cfgobj["dither"].as() == false) imageParams.dither = false; + if (cfgobj["dither"] && cfgobj["dither"] == "0") imageParams.dither = false; jpg2buffer(cfgobj["filename"].as(), filename, imageParams); if (imageParams.hasRed) imageParams.dataType = DATATYPE_IMG_RAW_2BPP; if (prepareDataAvail(&filename, imageParams.dataType, mac, cfgobj["timetolive"].as())) { diff --git a/ESP32_AP-Flasher/src/makeimage.cpp b/ESP32_AP-Flasher/src/makeimage.cpp index 743729b0..5578e84a 100644 --- a/ESP32_AP-Flasher/src/makeimage.cpp +++ b/ESP32_AP-Flasher/src/makeimage.cpp @@ -62,10 +62,31 @@ struct Error { float b; }; +// Gamma brightness lookup table +// gamma = 1.50 steps = 256 range = 0-255 +const uint8_t gamma_lut[256] PROGMEM = { + 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, + 4, 4, 5, 5, 6, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, + 11, 12, 12, 13, 14, 14, 15, 15, 16, 16, 17, 18, 18, 19, 20, 20, + 21, 21, 22, 23, 23, 24, 25, 26, 26, 27, 28, 28, 29, 30, 31, 31, + 32, 33, 34, 34, 35, 36, 37, 37, 38, 39, 40, 41, 41, 42, 43, 44, + 45, 46, 46, 47, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 58, + 59, 60, 61, 62, 63, 64, 65, 65, 66, 67, 68, 69, 70, 71, 72, 73, + 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 88, 89, 90, + 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 102, 103, 104, 105, 106, 107, + 108, 109, 110, 112, 113, 114, 115, 116, 117, 119, 120, 121, 122, 123, 124, 126, + 127, 128, 129, 130, 132, 133, 134, 135, 136, 138, 139, 140, 141, 142, 144, 145, + 146, 147, 149, 150, 151, 152, 154, 155, 156, 158, 159, 160, 161, 163, 164, 165, + 167, 168, 169, 171, 172, 173, 174, 176, 177, 178, 180, 181, 182, 184, 185, 187, + 188, 189, 191, 192, 193, 195, 196, 197, 199, 200, 202, 203, 204, 206, 207, 209, + 210, 211, 213, 214, 216, 217, 218, 220, 221, 223, 224, 226, 227, 228, 230, 231, + 233, 234, 236, 237, 239, 240, 242, 243, 245, 246, 248, 249, 251, 252, 254, 255, +}; + uint32_t colorDistance(const Color &c1, const Color &c2, const Error &e1) { - float r_diff = c1.r + e1.r - c2.r; - float g_diff = c1.g + e1.g - c2.g; - float b_diff = c1.b + e1.b - c2.b; + float r_diff = gamma_lut[c1.r] + e1.r - gamma_lut[c2.r]; + float g_diff = gamma_lut[c1.g] + e1.g - gamma_lut[c2.g]; + float b_diff = gamma_lut[c1.b] + e1.b - gamma_lut[c2.b]; return round(r_diff * r_diff + g_diff * g_diff + b_diff * b_diff); } diff --git a/ESP32_AP-Flasher/src/web.cpp b/ESP32_AP-Flasher/src/web.cpp index 427092b5..19da034a 100644 --- a/ESP32_AP-Flasher/src/web.cpp +++ b/ESP32_AP-Flasher/src/web.cpp @@ -365,12 +365,16 @@ void doImageUpload(AsyncWebServerRequest *request, String filename, size_t index request->_tempFile.close(); if (request->hasParam("mac", true)) { String dst = request->getParam("mac", true)->value(); + bool dither = true; + if (request->hasParam("dither", true)) { + if (request->getParam("dither", true)->value() == "0") dither = false; + } uint8_t mac[6]; if (sscanf(dst.c_str(), "%02X%02X%02X%02X%02X%02X", &mac[0], &mac[1], &mac[2], &mac[3], &mac[4], &mac[5]) == 6) { tagRecord *taginfo = nullptr; taginfo = tagRecord::findByMAC(mac); if (taginfo != nullptr) { - taginfo->modeConfigJson = "{\"filename\":\"" + dst + ".jpg\",\"timetolive\":\"0\"}"; + taginfo->modeConfigJson = "{\"filename\":\"" + dst + ".jpg\",\"timetolive\":\"0\",\"dither\":\"" + String(dither) + "\"}"; taginfo->contentMode = 0; taginfo->nextupdate = 0; wsSendTaginfo(mac); diff --git a/zbs243_AP_FW/packagebinaries.py b/zbs243_AP_FW/packagebinaries.py new file mode 100644 index 00000000..ba1fd18f --- /dev/null +++ b/zbs243_AP_FW/packagebinaries.py @@ -0,0 +1,57 @@ +import os +import json + +types = { + 0x00: "AP_FW_1.54.bin", + 0x01: "AP_FW_2.9.bin", + 0xF0: "AP_FW_Segmented_UK.bin", + 0xFF: "AP_FW_Nodisplay.bin" +} + +binpath = "../binaries/" +tocmaxsize = 512 + +toc = [] +output = bytearray(tocmaxsize) + +# Read version from main.c +with open("main.c") as file: + lines = file.readlines() + version_line = next(line for line in lines if "version" in line and "uint16_t" in line) + _, version = version_line.split("= 0x") + version = int(version.strip().split(";")[0], 16) + +binaries = [file for file in os.listdir(binpath) if file.startswith("AP_FW") and not file.endswith("Pack")] +for file in binaries: + file = file.strip() + type_id = -1 + for typeid, typefile in types.items(): + if typefile == file: + type_id = typeid + break + if type_id == -1: + raise ValueError("We don't recognize filetype <{}>, sorry...".format(file)) + with open(os.path.join(binpath, file), "rb") as binary_file: + binary = binary_file.read() + length = len(binary) + offset = len(output) + subarr = { + 'type': type_id, + 'version': version, + 'name': file, + 'offset': offset, + 'length': length + } + toc.append(subarr) + output.extend(binary) + +jtoc = json.dumps(toc) +tocsize = len(jtoc) +if tocsize > tocmaxsize: + raise ValueError("TOC is too big! Adjust size and try again.") +output[:len(jtoc)] = jtoc.encode() +with open(os.path.join(binpath, "AP_FW_Pack.bin"), "wb") as output_file: + output_file.write(bytes(output)) + +print(toc) +print("All done.")