diff --git a/Firmware/ATC_Paper.bin b/Firmware/ATC_Paper.bin index ec70fcc..a92763d 100644 Binary files a/Firmware/ATC_Paper.bin and b/Firmware/ATC_Paper.bin differ diff --git a/Firmware/src/epd_ble_service.c b/Firmware/src/epd_ble_service.c index 27eabb0..fbd19ad 100644 --- a/Firmware/src/epd_ble_service.c +++ b/Firmware/src/epd_ble_service.c @@ -15,7 +15,7 @@ extern uint8_t *epd_temp; return 0; \ } -extern unsigned char epd_buffer[epd_buffer_size]; +extern uint8_t epd_buffer[epd_buffer_size]; unsigned int byte_pos = 0; int epd_ble_handle_write(void *p) @@ -47,16 +47,14 @@ int epd_ble_handle_write(void *p) return 0; // Write data to image buffer. case 0x03: - if (byte_pos + payload_len - 1 >= epd_buffer_size + 1) + if ((payload[1] << 8 | payload[2]) + payload_len - 3 >= epd_buffer_size + 1) { out_buffer[0] = 0x00; out_buffer[1] = 0x00; bls_att_pushNotifyData(EPD_BLE_CMD_OUT_DP_H, out_buffer, 2); return 0; } - memcpy(epd_buffer + byte_pos, payload + 1, payload_len - 1); - - byte_pos += payload_len - 1; + memcpy(epd_buffer + (payload[1] << 8 | payload[2]), payload + 3, payload_len - 3); out_buffer[0] = payload_len >> 8; out_buffer[1] = payload_len & 0xff; diff --git a/readme.md b/readme.md index 0afbe3e..a39a829 100644 --- a/readme.md +++ b/readme.md @@ -63,9 +63,11 @@ Firmware CRC32: 0xe62d501e - [x] 添加蓝牙上传图片后notify - [x] 添加场景且支持切换 - [x] 图片模式 -- [ ] web 支持图片切换 +- [x] web 支持图片切换 - [x] 添加新的时间场景 - [x] 支持设置年月日 +- [x] web 支持画图编辑,直接上传图片,抖动算法 +- [ ] 三色抖动算法、设备端三色显示支持,蓝牙传输支持 ### 计划新增 - [ ] 安卓端控制器 diff --git a/tools/data/images/car-sign.png b/tools/data/images/car-sign.png new file mode 100644 index 0000000..0a0b2c7 Binary files /dev/null and b/tools/data/images/car-sign.png differ diff --git a/tools/requriments.txt b/tools/requriments.txt index e69de29..4fcc503 100644 --- a/tools/requriments.txt +++ b/tools/requriments.txt @@ -0,0 +1 @@ +Pillow==9.1.0 \ No newline at end of file diff --git a/tools/scripts/image2hex.py b/tools/scripts/image2hex.py index 7f49765..219b9ff 100644 --- a/tools/scripts/image2hex.py +++ b/tools/scripts/image2hex.py @@ -1,8 +1,10 @@ -from PIL import Image +from PIL.Image import Dither # noqa from tools.utils import image2hex, load_test_image if __name__ == '__main__': # print(image2hex(load_test_image('test-01.bmp'))) - print(image2hex(load_test_image('test-02.bmp'))) + + print(image2hex(load_test_image('mao.bmp'), dither=Dither.FLOYDSTEINBERG)) + print(image2hex(load_test_image('car-sign.png'), dither=Dither.NONE)) diff --git a/tools/utils.py b/tools/utils.py index eefa655..8065e6c 100644 --- a/tools/utils.py +++ b/tools/utils.py @@ -1,6 +1,7 @@ import os from PIL import Image +from PIL.Image import Dither # noqa def hex2bytes(hex_string): @@ -29,11 +30,13 @@ def hex2image(hex_string, width, height): image.show('test') -def image2hex(image, width=296, height=128): +def image2hex(image, width=296, height=128, dither=Dither.NONE): if isinstance(image, str): image = Image.open(image) - return bytes2hex(image.resize((width, height)).rotate(90, expand=True).resize((height, width)).convert('1').tobytes()) + image = image.resize((width, height)).rotate(90, expand=True) + # image.show() + return bytes2hex(image.resize((height, width)).convert('1', dither=dither).tobytes()) def load_test_image(name): diff --git a/web_tools/index.html b/web_tools/index.html index f8afb16..97dfa86 100644 --- a/web_tools/index.html +++ b/web_tools/index.html @@ -4,6 +4,25 @@ 电子价签蓝牙控制器 + + + @@ -51,6 +70,8 @@ async function sendCommand(cmd) { if (epdCharacteristic) { await epdCharacteristic.writeValueWithResponse(cmd) + } else { + addLog('服务不可用。蓝牙链接上了吗?') } } @@ -63,10 +84,12 @@ async function rxTxSendCommand(cmd) { if (rxtxCharacteristic) { await rxtxCharacteristic.writeValueWithResponse(cmd); + } else { + addLog('服务不可用。蓝牙链接上了吗?') } } - async function upload_tiff() { + async function upload_tiff_image() { const startTime = new Date().getTime(); const buffer = await document.getElementById('tiff_file').files[0].arrayBuffer(); const arr = bytesToHex(buffer); @@ -80,7 +103,7 @@ const step = 480; let partIndex = 0; for (let i = 0; i < arr.length; i += step) { - addLog(`正在上传第${partIndex+1}块. 块大小: ${step + 2}byte`); + addLog(`正在上传第${partIndex+1}块. 块大小: ${step + 1}byte`); await sendCommand(hexToBytes("03" + arr.slice(i, i + step))); partIndex += 1; } @@ -89,6 +112,31 @@ addLog(`上传tiff完成,耗时${(new Date().getTime() - startTime)/1000}s`); } + async function upload_image() { + const canvas = document.getElementById('canvas'); + const value = bytesToHex(canvas2bytes(canvas)); + + const startTime = new Date().getTime(); + + addLog(`开始刷新屏幕内存, 大小 ${value.length/2/1024}KB`); + + await sendCommand(hexToBytes("0000")); + + await sendCommand(hexToBytes("020000")); + + const step = 480; + let partIndex = 0; + for (let i = 0; i < value.length; i += step) { + addLog(`正在发送第${partIndex+1}块. 块大小: ${step/2 + 3}byte. 起始位置: ${i/2}`); + await sendCommand(hexToBytes("03" + intToHex(i / 2, 2) + value.substring(i, i + step))); + partIndex += 1; + } + + await sendCommand(hexToBytes("01")) + + addLog(`刷新完成,耗时${(new Date().getTime() - startTime)/1000}s`); + } + async function upload_epd_buffer() { const startTime = new Date().getTime(); const value = document.getElementById('buffer').value.replace(/(?:\r\n|\r|\n|,|0x| )/g, ''); @@ -102,16 +150,13 @@ const step = 480; let partIndex = 0; for (let i = 0; i < value.length; i += step) { - addLog(`正在发送第${partIndex+1}块. 块大小: ${step + 2}byte`); - await sendCommand(hexToBytes("03" + value.substring(i, i + step))); - await delay(50); + addLog(`正在发送第${partIndex+1}块. 块大小: ${step/2 + 3}byte. 起始位置: ${i/2}`); + await sendCommand(hexToBytes("03" + intToHex(i / 2, 2) + value.substring(i, i + step))); partIndex += 1; } - await sendCommand(hexToBytes("03" + value.substring(value.length-step, value.length))); - await delay(50); + await delay(150); await sendCommand(hexToBytes("01")) - await delay(50); addLog(`刷新完成,耗时${(new Date().getTime() - startTime)/1000}s`); } @@ -176,10 +221,6 @@ setTimeout(async function () { await connect(); }, 300); } - function handleNotify(data) { - addLog("接受到字节: " + bytesToHex(data.buffer)); - } - async function connect() { if (epdCharacteristic == null) { addLog("正在链接: " + bleDevice.name); @@ -197,7 +238,7 @@ epdCharacteristic.addEventListener('characteristicvaluechanged', (event) => { console.log('epd ret', bytesToHex(event.target.value.buffer)) - const count = parseInt('0x'+ bytesToHex(event.target.value.buffer)) * 2; + const count = parseInt('0x'+ bytesToHex(event.target.value.buffer)); addLog(`> [来自屏幕]: 收到${count} byte数据`); }); @@ -220,23 +261,6 @@ dom.scrollTop = dom.scrollHeight; } - function hexToBytes(hex) { - for (var bytes = [], c = 0; c < hex.length; c += 2) - bytes.push(parseInt(hex.substr(c, 2), 16)); - return new Uint8Array(bytes); - } - - function bytesToHex(data) { - return new Uint8Array(data).reduce( - function (memo, i) { - return memo + ("0" + i.toString(16)).slice(-2); - }, ""); - } - - function intToHex(intIn, bytes=4) { - return intIn.toString(16).padStart(bytes * 2, '0'); - } - function getUnixTime() { const hourOffset = document.getElementById('hour-offset').value; const unixNow = Math.round(Date.now() / 1000)+(60*60*hourOffset) - new Date().getTimezoneOffset() * 60; @@ -244,24 +268,223 @@ const date = new Date((unixNow + new Date().getTimezoneOffset() * 60)*1000); const localeTimeString = date.toLocaleTimeString(); - return {unixNow, localeTimeString, year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), week: date.getDay()} + return {unixNow, localeTimeString, year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), week: date.getDay() || 7} + } + + async function update_image () { + const image_file = document.getElementById('image_file'); + if (image_file.files.length > 0) { + const file = image_file.files[0]; + + const canvas = document.getElementById("canvas"); + const ctx = canvas.getContext("2d"); + + const image = new Image(); + image.src = URL.createObjectURL(file); + image.onload = function(event) { + URL.revokeObjectURL(this.src); + ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height); + convert_dithering() + } + } + } + + function get_position(canvas, x, y){ + let rect = canvas.getBoundingClientRect() + return { + x: x - rect.left * (canvas.width/rect.width), + y: y - rect.top * (canvas.height/rect.height) + } + } + + function clear_canvas() { + if(confirm('确认清除屏幕?')) { + const canvas = document.getElementById('canvas'); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + } + + function convert_dithering() { + const canvas = document.getElementById('canvas'); + const ctx = canvas.getContext("2d"); + dithering(ctx, canvas.width, canvas.height, parseInt(document.getElementById('threshold').value), document.getElementById('dithering').value); } document.body.onload = () => { setInterval(() => { const { localeTimeString, year, month, day, week } = getUnixTime(); document.getElementById('time-setter').innerText = `设置时间为:${year}-${month}-${day} ${localeTimeString} 星期${week}`; - }, 1) - } + }, 1000); + const canvas = document.getElementById('canvas'); + + let is_allow_drawing = false; + let is_allow_move_editor = false; + const image_mode = document.getElementById('canvas-mode'); + const paint_size = document.getElementById('paint-size'); + const paint_color = document.getElementById('paint-color'); + const editor = document.getElementById('edit-font'); + const font = document.getElementById('font'); + document.getElementById('dithering').value = 'Atkinson'; + image_mode.value = 'paint'; + paint_color.value = 'black'; + font.value = '黑体'; + + editor.onmousemove = function (e) { + editor.style.fontSize = `${paint_size.value * 10}px`; + editor.style.color = paint_color.value; + editor.style.fontFamily = font.value; + + if (is_allow_move_editor) { + const {x, y} = get_position(canvas, e.clientX, e.clientY); + if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) { + return; + } + + editor.style.left = `${e.clientX-50}px`; + editor.style.top = `${e.clientY-50}px`; + + } + } + + editor.onmousedown = function (e) { + is_allow_move_editor = true; + } + + editor.onmouseup = function (e) { + is_allow_move_editor = false; + } + + document.getElementById('update-text').onclick = function () { + if (!editor.value.length) { + alert('请先输入文字'); + return; + } + editor.style.display = 'none'; + const ctx = canvas.getContext("2d"); + ctx.beginPath(); + ctx.font = `${paint_size.value * 10}px ${font.value}`; + ctx.fillStyle = paint_color.value; + const {x, y} = get_position(canvas, parseInt(editor.style.left), parseInt(editor.style.top) + paint_size.value * 10); + + ctx.fillText(editor.value, x, y); + } + + image_mode.onchange = function (e) { + if (image_mode.value === 'font') { + document.getElementById('update-text').style.display = 'inline-block'; + document.getElementById('font').style.display = 'inline-block'; + + editor.style.display='block'; + editor.style.left = `${e.clientX}px`; + editor.style.top = `${e.clientY}px`; + return; + } + document.getElementById('update-text').style.display = 'none'; + document.getElementById('font').style.display = 'none'; + editor.style.display='none'; + } + + paint_size.onchange = function () { + if (image_mode.value === 'font') { + editor.style.fontSize = `${paint_size.value * 10}px`; + } + } + + paint_color.onchange = function () { + if (image_mode.value === 'font') { + editor.style.color = paint_color.value; + } + } + + font.onchange = function () { + if (image_mode.value === 'font') { + editor.style.fontFamily = font.value; + } + } + + canvas.onmousedown = function(e) { + let ele = get_position(canvas, e.clientX, e.clientY) + let { x, y } = ele + const ctx = canvas.getContext("2d"); + + switch (image_mode.value) { + case 'paint': + is_allow_drawing = true; + ctx.beginPath(); + ctx.moveTo(x, y); + break; + case 'font': + if (editor.style.display === 'none') { + editor.style.display='block'; + editor.style.left = `${e.clientX}px`; + editor.style.top = `${e.clientY}px`; + editor.style.fontSize = `${paint_size.value * 10}px`; + editor.style.color = paint_color.value; + editor.style.fontFamily = font.value; + } + + break + default: + break; + } + }; + + canvas.onmousemove = (e) => { + const ctx = canvas.getContext("2d"); + let ele = get_position(canvas, e.clientX, e.clientY) + let { x, y } = ele; + switch (image_mode.value) { + case 'paint': + if (is_allow_drawing) { + ctx.lineWidth = paint_size.value; + ctx.strokeStyle=paint_color.value; + ctx.lineTo(x, y); + ctx.stroke(); + } + break; + case 'font': + break; + + default: + break; + } + } + + canvas.onmouseup = function() { + switch (image_mode.value) { + case 'paint': + is_allow_drawing = false; + break; + + case 'font': + editor.focus(); + is_allow_move_editor = false; + break; + + default: + break; + } + } + + canvas.onmouseleave = function () { + if (image_mode.value === 'paint') { + is_allow_drawing = false; + } + } + }

电子价签蓝牙控制器

+ 串口升级串口升级 +


+

指令控制


@@ -273,20 +496,69 @@

- +

上传图片到屏幕

+ + 抖动算法: + + 阈值: + +
-
+ +
+
+ 模式: + + 画笔/文字大小: + + 画笔颜色: + + + + + +
+
+ + +
+ +
+

- 上传tiff到屏幕 -
- -
-
-
+

设置时间


偏移+小时 +
+
+

屏幕控制

+ + +
+
+
+
日志: diff --git a/web_tools/js/dithering.js b/web_tools/js/dithering.js new file mode 100644 index 0000000..34f453b --- /dev/null +++ b/web_tools/js/dithering.js @@ -0,0 +1,148 @@ +const bwrPalette = [ + [0, 0, 0, 0], + [255, 255, 255, 0], + [255, 0, 0, 0] +] + +const bwPalette = [ + [0, 0, 0, 0], + [255, 255, 255, 0], +] + +function get_near_color(color, palette) { + let minDistanceSquared = 255*255 + 255*255 + 255*255 + 1; + + let bestIndex = 0; + for (let i = 0; i < palette.length; i++) { + let rdiff = (color[0] & 0xff) - (palette[i][0] & 0xff); + let gdiff = (color[1] & 0xff) - (palette[i][1] & 0xff); + let bdiff = (color[2] & 0xff) - (palette[i][2] & 0xff); + let distanceSquared = rdiff*rdiff + gdiff*gdiff + bdiff*bdiff; + if (distanceSquared < minDistanceSquared) { + minDistanceSquared = distanceSquared; + bestIndex = i; + } + } + return bestIndex; + +} + +function updatePixel(imageData, index, color) { + imageData[index] = color[0]; + imageData[index+1] = color[1]; + imageData[index+2] = color[2]; + imageData[index+3] = color[3]; +} + +function dithering(ctx, width, height, threshold, type) { + const bayerThresholdMap = [ + [ 15, 135, 45, 165 ], + [ 195, 75, 225, 105 ], + [ 60, 180, 30, 150 ], + [ 240, 120, 210, 90 ] + ]; + + const lumR = []; + const lumG = []; + const lumB = []; + for (let i=0; i<256; i++) { + lumR[i] = i*0.299; + lumG[i] = i*0.587; + lumB[i] = i*0.114; + } + const imageData = ctx.getImageData(0, 0, width, height); + + const imageDataLength = imageData.data.length; + + // Greyscale luminance (sets r pixels to luminance of rgb) + for (let i = 0; i <= imageDataLength; i += 4) { + imageData.data[i] = Math.floor(lumR[imageData.data[i]] + lumG[imageData.data[i+1]] + lumB[imageData.data[i+2]]); + } + + const w = imageData.width; + let newPixel, err; + + for (let currentPixel = 0; currentPixel <= imageDataLength; currentPixel+=4) { + + if (type ==="none") { + // No dithering + imageData.data[currentPixel] = imageData.data[currentPixel] < threshold ? 0 : 255; + } else if (type ==="bayer") { + // 4x4 Bayer ordered dithering algorithm + var x = currentPixel/4 % w; + var y = Math.floor(currentPixel/4 / w); + var map = Math.floor( (imageData.data[currentPixel] + bayerThresholdMap[x%4][y%4]) / 2 ); + imageData.data[currentPixel] = (map < threshold) ? 0 : 255; + } else if (type ==="floydsteinberg") { + // Floyda€"Steinberg dithering algorithm + newPixel = imageData.data[currentPixel] < 129 ? 0 : 255; + err = Math.floor((imageData.data[currentPixel] - newPixel) / 16); + imageData.data[currentPixel] = newPixel; + + imageData.data[currentPixel + 4 ] += err*7; + imageData.data[currentPixel + 4*w - 4 ] += err*3; + imageData.data[currentPixel + 4*w ] += err*5; + imageData.data[currentPixel + 4*w + 4 ] += err*1; + } else { + // Bill Atkinson's dithering algorithm + newPixel = imageData.data[currentPixel] < threshold ? 0 : 255; + err = Math.floor((imageData.data[currentPixel] - newPixel) / 8); + imageData.data[currentPixel] = newPixel; + + imageData.data[currentPixel + 4 ] += err; + imageData.data[currentPixel + 8 ] += err; + imageData.data[currentPixel + 4*w - 4 ] += err; + imageData.data[currentPixel + 4*w ] += err; + imageData.data[currentPixel + 4*w + 4 ] += err; + imageData.data[currentPixel + 8*w ] += err; + } + + // Set g and b pixels equal to r + imageData.data[currentPixel + 1] = imageData.data[currentPixel + 2] = imageData.data[currentPixel]; + } + + ctx.putImageData(imageData, 0, 0); +} + +function canvas2bytes(canvas, rotate=1) { + const ctx = canvas.getContext("2d"); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + const arr = []; + let buffer = []; + + for (let x = canvas.width - 1; x >= 0; x--) { + for (let y = 0; y < canvas.height; y++) { + const index = (canvas.width * 4 * y) + x * 4; + buffer.push(imageData.data[index] > 0 ? 1 : 0); + if (buffer.length === 8) { + arr.push(parseInt(buffer.join(''), 2)); + buffer = []; + } + } + } + return arr; +} + +function scaleImageData(canvas, scale) { + const ctx = canvas.getContext("2d"); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const scaled = ctx.createImageData(imageData.width * scale, imageData.height * scale); + const subLine = ctx.createImageData(scale, 1).data + for (let row = 0; row < imageData.height; row++) { + for (let col = 0; col < imageData.width; col++) { + let sourcePixel = imageData.data.subarray( + (row * imageData.width + col) * 4, + (row * imageData.width + col) * 4 + 4 + ); + for (let x = 0; x < scale; x++) subLine.set(sourcePixel, x*4) + for (let y = 0; y < scale; y++) { + let destRow = row * scale + y; + let destCol = col * scale; + scaled.data.set(subLine, (destRow * scaled.width + destCol) * 4) + } + } + } + + return scaled; +} \ No newline at end of file diff --git a/web_tools/js/utils.js b/web_tools/js/utils.js new file mode 100644 index 0000000..7c827c8 --- /dev/null +++ b/web_tools/js/utils.js @@ -0,0 +1,17 @@ + +function hexToBytes(hex) { + for (var bytes = [], c = 0; c < hex.length; c += 2) + bytes.push(parseInt(hex.substr(c, 2), 16)); + return new Uint8Array(bytes); +} + +function bytesToHex(data) { + return new Uint8Array(data).reduce( + function (memo, i) { + return memo + ("0" + i.toString(16)).slice(-2); + }, ""); +} + +function intToHex(intIn, bytes=4) { + return intIn.toString(16).padStart(bytes * 2, '0'); +} diff --git a/web_tools/Hanshow TLSR8359 E-Paper Pricetag Flasher.html b/web_tools/uart_flasher.html similarity index 100% rename from web_tools/Hanshow TLSR8359 E-Paper Pricetag Flasher.html rename to web_tools/uart_flasher.html