2025寒假在家一起练 - 基于CrowPanel ESP32 Display 4.3英寸HMI开发板的手写识别显示
该项目使用了CrowPanel ESP32 Display 4.3英寸HMI开发板、Micropython语言,实现了手写识别显示的设计,它的主要功能为:利用lvgl创建的ui获取手写数字并利用多元感知器神经网络进行手写数字的识别。 该项目使用了硬禾实战营焊接训练用LED点阵灯板和ESP32-S2-MINI-1开发板、Micropython语言,实现了物联网LED灯板的设计,它的主要功能为:与客户机建立TCP连接后根据客户机的指令在LED灯板上显示相应的数字。
标签
嵌入式系统
AI
LED
串行总线
物联网
lvgl
LCD
触摸
2025寒假在家一起练
Mr.Wolf
更新2025-03-13
华中科技大学
165

一、项目介绍

本项目使用了CrowPanel ESP32 Display 4.3英寸HMI开发板与硬禾实战营焊接训练用LED点阵灯板同时搭配ESP32-S2-MINI-1开发板作为辅助,使用Micropython+lvgl开发,实现手写数字识别与显示。利用lvgl实现了手写数字的采集,并使用多层感知器神经网络对采集到的手写数字进行识别,最后利用TCP通讯将识别到的结果发送给LED灯板设备显示。

二、硬件介绍

1.CrowPanel ESP32 Display 4.3英寸HMI开发板

CrowPanel ESP32 Display 4.3英寸HMI开发板是一款功能强大的HMI触摸屏,具有480*272分辨率的LCD显示屏和一块IC NV3047驱动板,显示屏采用电阻式触摸技术

该开发板使用ESP32-S3-WROOM-1-N4R2模组作为主控处理器,具有双核32位 LX7处理器,集成WiFi和蓝牙无线功能,主频高达240MHz,提供强大的性能和多功能的应用,此外,板子预留了TF卡槽、多种外设接口、USB接口、喇叭接口、电池接口等,提供了更多的扩展可能。

2.硬禾实战营焊接训练用LED点阵灯板

该LED点阵灯板有8*8共64颗单色LED灯,同时搭配了64个0603封装的电阻,两颗串-并变换、SOIC-16封装的74HC595D、2颗0603封装的电源去耦电容、2个5管脚直插的连接器,8个NPN三极管9013和16个0603封装的电阻,用于驱动LED阵列,提供给每排8个LED提供点亮所需要的电流。

3.ESP32-S2-MINI-1开发板

该模块板载了ESP32-S2-MINI-1模组,这是一款2.4 GHz Wi­Fi 模组内置 ESP32­S2 系列芯片,Xtensa® 单核 32 位 LX7 微处理器,37 个 GPIO,具有丰富的外设,板载 PCB 天线。

除此之外,该开发板还集成了USB TYPE -C接口,两个按键,一个电源指示灯,一个用户LED灯,2排10pin的排针,将重要IO引出。使用USB供电或通过排针3.3V供电。

三、方案框图和项目设计思路

1.方案框图

2.项目设计思路

  1. 使用Micropython+lvgl创建ui并采集手写数字,以及将识别结果反馈到屏幕上
  2. CrowPanel ESP32 Display 4.3英寸HMI开发板上运行一个简单的MLP神经网络对采集到的手写数字进行识别
  3. 通过TCP套接字将识别的结果发送给LED灯板设备显示
  4. LED灯板设备上运行TCP服务端与主开发板建立通讯连接
  5. 按行扫描的方式将收到的字符绘制到LED灯板上

四、功能展示图及说明

本次实现的功能手写数字的采集与识别,并将识别的结果发送给LED灯板显示。

下面为图片展示及简要说明:

【图 1】

上图为项目的整体概述图,分为CrowPanel ESP32显示屏开发板部分和LED灯板部分。其中LED灯板部分可作为独立的物联网设备运行,本项目中采用5V锂电池组进行供电(图中蓝色设备即为锂电池);显示屏部分则使用USB线用电脑进行供电。

【图 2】

上图为仅启动LED灯板时的状态,灯板上的wifi信号样的图案(共有四帧,此处仅展示一帧)表示灯板的TCP服务端无线接入点已启动,正在等待客户机的连接。

【图 3】

上图为显示屏启动后的界面,此时CrowPanel ESP32开发板还未与LED灯板建立连接,故此时屏幕上显示灯板为离线(”Offilne“)状态,灯板上仍为wifi符号的动画等待客户机连接。

【图 4】

上图为点击”Connect“按钮后CrowPanel ESP32开发板与LED灯板建立连接后的状态。此时屏幕上显示LED的状态为在线(”Online“);同时灯板上的wifi信号样图案消失,清屏等待客户机的指令。

【图 5】

上图为CrowPanel ESP32开发板屏幕的特写。屏幕上部第一行为项目名称,第二行为LED灯板状态指示;屏幕中部左侧三个按钮从上到下分别为清屏(”Clear“)——清空画布上的内容、预测(”Predict“)——对画布上书写的内容进行一次预测、断开连接(”Disconnect“,未连接到LED灯板时为”Connect“连接)——断开与LED灯板的连接(未连接到LED灯板时则为尝试与LED灯板建立连接),屏幕中间黑色区为用于书写数字的手写画布,屏幕中部右侧的小狐狸则在点击预测按钮后通过文本框告诉用户他预测的数字,以增强此项目的互动性与趣味性;屏幕下部的三行文本则为帮助说明文档。

【图 6】

【图 7】

【图 8】

【图 9】

【图 10】

【图 11】

【图 12】

【图 13】

【图 14】

【图 15】

上图【图 6】-【图 15】为手写数字0-9的识别结果,证明了项目对于手写数字识别的成功。除了灯板上的显示结果外,屏幕上的小狐狸也通过右上角的文本给出了预测的结果,使项目不失活泼。

五、软件流程图和关键代码介绍

1.软件流程图

2.关键代码介绍

①CrowPanel ESP32 Display 4.3英寸HMI开发板部分

该部分作为此次手写识别显示任务的主要部分,共包含主程序文件、简单的多元感知机神经网络文件、简单封装过后的ui库文件、以及驱动设备类文件,下面将进一步对其进行介绍。

a.驱动设备类部分

该部分位于/dev文件夹下,包含SPI总线类、触摸屏驱动类和无线LED灯板(客户端)驱动类。

·SPI总线类(位于/dev/SPI_bus.py)

本类对触摸驱动使用的SPI总线进行了简单的封装,该封装也可扩展给其他使用该SPI总线的外设(如TF卡槽)使用,因为本次任务不涉及TF卡,故这里未作相应扩展。

from machine import SPI, Pin

class SPIBusDev:
"""
SPI总线设备类
"""
bus = SPI(2, baudrate=1000000, sck=Pin(12), mosi=Pin(11), miso=Pin(13))
touch_cs = Pin(0, mode=Pin.OUT, value=1)

@staticmethod
def get_cs_value():
"""
获取当前片选引脚的值
:return: 片选引脚的值字典
"""
return {"touch_cs":SPIBusDev.touch_cs.value()}

@staticmethod
def set_cs_value(touch_cs=1):
"""
设置片选引脚的值
:param touch_cs: 要设置的值
:return: None
"""
SPIBusDev.touch_cs.value(touch_cs)

·触摸屏驱动类(位于/dev/touch_screen.py)

本类基于ELECROW官方给出的屏幕和触摸驱动初始化文件,同时扩展了一个静态的get_touch方法以便直接从触摸驱动获取触摸事件的坐标,这将与画布组件一同使用。

import lvgl as lv
import lv_utils
import tft_config
from xpt import Touch
from machine import Pin

from dev.SPI_bus import SPIBusDev


class TouchScreenDev:
"""
触摸屏设备类
"""
WIDTH = 480 # 屏幕的宽
HEIGHT = 272 # 屏幕的高
instance = None # 触摸屏设备类的实例

def __init__(self):
pin38 = Pin(38, Pin.OUT)
pin38.value(0)

# tft驱动
try:
tft = tft_config.config()
except Exception as e:
print(e)
print("TFT Drive init failed")
print("Assume touch screen has already been initiated")
return

# 触摸驱动
int_pin = Pin(36)
self.xpt = Touch(SPIBusDev.bus, cs=SPIBusDev.touch_cs, int_pin=int_pin)
xmin = 96
xmax = 1951
ymin = 175
ymax = 1908
orientation = 0
self.xpt.calibrate(xmin, xmax, ymin, ymax, 480, 272, orientation)

# 初始化lvgl
lv.init()

if not lv_utils.event_loop.is_running():
event_loop=lv_utils.event_loop()
print("LVGL event loop is running:", event_loop.is_running())

# 创建display 0缓冲区
disp_buf0 = lv.disp_draw_buf_t()
buf1_0 = bytearray(TouchScreenDev.WIDTH * 10)
disp_buf0.init(buf1_0, None, len(buf1_0) // lv.color_t.__SIZE__)

# 注册显示驱动
disp_drv = lv.disp_drv_t()
disp_drv.init()
disp_drv.draw_buf = disp_buf0
disp_drv.flush_cb = tft.flush
disp_drv.hor_res = TouchScreenDev.WIDTH
disp_drv.ver_res = TouchScreenDev.HEIGHT
# disp_drv.user_data = {"swap": 0}
disp0 = disp_drv.register()
lv.disp_t.set_default(disp0)

# 初始化触摸驱动
indev_drv = lv.indev_drv_t()
indev_drv.init()
indev_drv.disp = disp0
indev_drv.type = lv.INDEV_TYPE.POINTER
indev_drv.read_cb = self.xpt.read
indev = indev_drv.register()

# 设置使用的主题
dispp = lv.disp_get_default()
theme = lv.theme_default_init(dispp, lv.palette_main(lv.PALETTE.BLUE), lv.palette_main(lv.PALETTE.RED), False, lv.font_default())
dispp.set_theme(theme)
TouchScreenDev.instance = self

print("Touch screen & LVGL initiate successfully")

@staticmethod
def get_touch():
"""
获取触摸事件的坐标
:return: 若有触摸事件则返回(x, y)格式的坐标,否则返回False
"""
if not TouchScreenDev.instance:
TouchScreenDev()

if TouchScreenDev.instance.xpt.is_touched():
return TouchScreenDev.instance.xpt.get_touch()
else:
return False

TouchScreenDev()

·无线LED灯板客户端驱动类(位于/dev/wireless_led_panel.py)

该类利用套接字实现了一个TCP客户端,用于与LED灯板(TCP服务端)建立连接并发送控制指令。

import network
import socket
import time

class WirelessLEDPanelDev:
"""
LED灯板客户端设备类
"""
def __init__(self, ssid="LED_Panel", password="matrix64"):
"""

:param ssid: 要连接的LED灯板的网络的SSID
:param password: 要连接的灯板的网络的密码
"""
# 开启网络
self.wlan = network.WLAN(network.STA_IF)
self.wlan.active(True)
self.password = password
self.ssid = ssid
self.isconnect = False

def connect(self):
"""
尝试连接到LED灯板网络
:return: 是否成功连接,成功为True,失败为False
"""
self.wlan.active(False)
self.wlan.active(True)
self.wlan.connect(self.ssid, self.password)
i = 0
while not self.wlan.isconnected():
time.sleep_ms(20)
i += 1
if i > 500:
# 连接超时
print("No connection")
self.isconnect = False
return False

# 连接成功,创建套接字
self.addr = socket.getaddrinfo("192.168.4.1", 5000)[0][-1]
self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.s.connect(('192.168.4.1', 5000))
self.isconnect = True
self.clear()
print("Wireless LED panel connected")
return True

def show(self, ch):
"""
让LED灯板显示指定字符
:param ch: 要显示的字符
:return: None
"""
if self.isconnect:
msg = ":char "+str(ch)[0]
self.s.send(msg.encode())

def clear(self):
"""
让LED灯板清屏
:return: None
"""
if self.isconnect:
msg = b":clear"
self.s.send(msg)

def quit(self):
"""
退出与LED灯板的连接
:return: None
"""
if self.isconnect:
msg = b":quit"
self.s.send(msg)

def close(self):
"""
关闭与LED灯板的连接
:return:
"""
self.isconnect = False
self.quit()
self.s.close()

def __del__(self):
try:
self.s.close()
except:
pass

b.多元感知机神经网络

该部分位于/lib/MicroNN.py文件中,为一个不依赖第三方数学库的简单多层感知网络(MLP)模型,该模型足够轻量,完成手写数字识别的权重文件大小仅54KB,核心代码文件仅215行,很方便在资源受限的系统上运行。

随着现在AI技术持续火爆,端侧AI模型也越来越被重视,但是现有的支持微控制器的神经网络大多都需要使用C/C++进行开发,为数不多支持Micropython的神经网络大多都需要专有硬件或需要被预先编译进固件里,而这边官方提供的固件并不包含类似的库,所以我采用了自行编写的一个神经网络模型,该模型仅使用Micropython通用库,故可以很方便的移植;而且如果将引用的Micropython库换成Python标准库的话,该神经网络也可被移植到电脑上,故可以利用电脑的高算力对神经网络进行训练,之后再将训练好的权重文件移植到Micropython设备中。

该神经网络虽然仅有215行,但包含完整的训练方法、预测方法、模型评估方法、数据洗牌算法、激活函数及其导数、权重的保存与加载方法,其中训练方法还包括误差的反向传播,可谓麻雀虽小五脏俱全。下面将对MLP神经网络核心代码进行展示与介绍,让我们有请此次任务中最为核心的嘉宾闪亮登场:

import math
import ujson
import urandom

class MLP:
"""
不依赖第三方数学库的简单的易移植的多层感知器神经网络
"""
def __init__(self, input_size, hidden_sizes, output_size):
"""

:param input_size: 输入层大小
:param hidden_sizes: 隐藏层大小列表
:param output_size: 输出层大小
"""
self.layer_sizes = [input_size] + hidden_sizes + [output_size]
self.num_layers = len(self.layer_sizes) - 1

self.weights = []
self.biases = []
# 用随机数初始化权重和偏置
for i in range(self.num_layers):
layer_weights = [
[self._random_weight() for _ in range(self.layer_sizes[i+1])]
for _ in range(self.layer_sizes[i])
]
layer_biases = [self._random_weight() for _ in range(self.layer_sizes[i+1])]
self.weights.append(layer_weights)
self.biases.append(layer_biases)

@staticmethod
def _random_weight():
"""
生成随机数
:return: 生成的随机数
"""
return (urandom.getrandbits(16)/65535 * 2) - 1 # 使用硬件随机数生成器

@staticmethod
def shuffle_data(data):
"""
对传入的数据使用Fisher-Yates算法打乱
:param data: 需要打乱的数据
:return: 打乱后的数据
"""
# Fisher-Yates洗牌算法
for i in range(len(data)-1, 0, -1):
j = urandom.getrandbits(16) % (i+1)
data[i], data[j] = data[j], data[i]
return data

@staticmethod
def _sigmoid(x):
"""
sigmoid激活函数
:param x: 自变量
:return: 因变量的值
"""
return 1 / (1 + math.exp(-x))

@staticmethod
def _sigmoid_derivative(x):
"""
sigmoid激活函数的导数
:param x: 自变量
:return: 因变量的值
"""
return x * (1 - x)

def forward(self, inputs):
"""
向前传播
:param inputs: 输入
:return: 输出
"""
self.activations = [inputs.copy()]
self.z_values = []

for layer in range(self.num_layers):
current_activation = self.activations[-1]
z = []
for j in range(self.layer_sizes[layer+1]):
weighted_sum = sum(
current_activation[i] * self.weights[layer][i][j]
for i in range(len(current_activation))
)
z.append(weighted_sum + self.biases[layer][j])

self.z_values.append(z)
activation = [self._sigmoid(zj) for zj in z]
self.activations.append(activation)

return self.activations[-1]

def train_step(self, inputs, targets, learning_rate):
"""
单步训练
:param inputs: 输入
:param targets: 目标输出
:param learning_rate: 学习率
:return: None
"""
outputs = self.forward(inputs)

# 反向传播
deltas = []
output_error = [targets[i] - outputs[i] for i in range(len(outputs))]
output_delta = [output_error[i] * self._sigmoid_derivative(outputs[i]) for i in range(len(outputs))]
deltas.insert(0, output_delta)

for layer in reversed(range(self.num_layers - 1)):
current_delta = []
for i in range(len(self.activations[layer+1])):
error = sum(
self.weights[layer+1][i][j] * deltas[0][j]
for j in range(len(deltas[0]))
)
derivative = self._sigmoid_derivative(self.activations[layer+1][i])
current_delta.append(error * derivative)
deltas.insert(0, current_delta)

# 更新参数
for layer in range(self.num_layers):
for i in range(len(self.weights[layer])):
for j in range(len(self.weights[layer][i])):
self.weights[layer][i][j] += learning_rate * self.activations[layer][i] * deltas[layer][j]
for j in range(len(self.biases[layer])):
self.biases[layer][j] += learning_rate * deltas[layer][j]

def evaluate(self, dataset):
"""
评估数据集的损失
:param dataset: 数据集
:return: 损失的值
"""
total_error = 0
for inputs, targets in dataset:
outputs = self.forward(inputs)
total_error += sum((targets[i] - outputs[i])**2 for i in range(len(targets)))
return total_error / len(dataset)

def fit(self, train_data, val_data, epochs, learning_rate=0.1, verbose=True):
"""
完整的训练流程
:param train_data: 训练集数据
:param val_data: 验证集数据
:param epochs: 训练轮次
:param learning_rate: 学习率
:param verbose: 是否打印每一轮训练的结果
:return: None
"""
for epoch in range(1, epochs+1):
# 打乱训练数据顺序(保持样本-标签对应)
shuffled_data = self.shuffle_data(train_data.copy())

# 训练阶段
for inputs, targets in shuffled_data:
self.train_step(inputs, targets, learning_rate)

# 计算损失
train_loss = self.evaluate(train_data)
val_loss = self.evaluate(val_data)

if verbose:
print(f"Epoch {epoch}/{epochs} |",
f"Train Loss: {train_loss:.4f} |",
f"Val Loss: {val_loss:.4f}")

def predict(self, inputs):
"""
进行一次预测
:param inputs: 待预测的输入
:return: 预测结果
"""
return self.forward(inputs)

def save_weights(self, filename):
"""
将训练的权重文件保存到指定json文件种
:param filename: 保存权重文件的路径
:return: None
"""
weights_dict = {
'layer_sizes': self.layer_sizes,
'weights': self.weights,
'biases': self.biases
}
with open(filename, 'w') as f:
ujson.dump(weights_dict, f)

def load_weights(self, filename):
"""
从指定json文件中加载权重
:param filename: 加载权重文件的路径
:return: None
"""
with open(filename, 'r') as f:
weights_dict = ujson.load(f)
self.layer_sizes = weights_dict['layer_sizes']
self.weights = weights_dict['weights']
self.biases = weights_dict['biases']
self.num_layers = len(self.layer_sizes) - 1

@staticmethod
def get_max(data):
"""
返回输入的列表中最大数字的索引
:param data: 目标列表
:return: 最大数字的索引
"""
max_index = 0
for ii in range(1, len(data)):
if data[ii] >= data[max_index]:
max_index = ii
return max_index

c.简单封装的ui库

因为Micropython绑定的lvgl库是根据C语言源代码生成的,故其在使用时仍保留了部分C语言的代码风格。为了简化调用,我对其进行了简单的封装,根据项目需求封装了文本组件、图片组件、画布组件和按钮组件。封装后的库位于/lib/ui文件夹下,下面将对其主要组件进行讲解。

·文本组件(位于/lib/ui/ui_text.py)

该部分基于lvgl的label组件,除了实现设置自身文本内容、设置自身可见性、设置自身位置等常规方法外,还实现了一个类似于python中print函数将若干输入拼接成一段文本并显示之的方法。

import lvgl as lv

class Text:
"""
对lvgl中文本组件的封装
"""
def __init__(self, text, parent_scr, align=None):
"""

:param text: 文本内容
:param parent_scr: 父组件
:param align: 文本位置
"""
self.text = text
self.parent = parent_scr

if align is None:
self.align = (lv.ALIGN.CENTER, 0, 0)
else:
self.align = align

self.label = lv.label(parent_scr)
self.label.set_text(text)
self.label.align(*self.align)
self.has_show = True

def toggle_visibility(self):
"""
反转自身的可见性
:return: None
"""
if self.has_show:
self.label.add_flag(lv.obj.FLAG.HIDDEN) # 添加隐藏标志
self.has_show = False
else:
self.label.clear_flag(lv.obj.FLAG.HIDDEN) # 设置隐藏标志
self.has_show = True

def show(self, align=None, redraw=False):
"""
设置自身状态为可见
:param align: 文本位置
:param redraw: 是否立即触发屏幕加载
:return: None
"""
if align is not None:
self.align = align
self.label.align(*align)

if redraw:
lv.scr_load(self.parent)

if not self.has_show:
self.label.clear_flag(lv.obj.FLAG.HIDDEN)
self.has_show = True

def hide(self):
"""
设置自身状态为不可见
:return: None
"""
self.label.add_flag(lv.obj.FLAG.HIDDEN)
self.has_show = False

def set_text(self, txt):
"""
设置文本
:param txt: 要设置的新文本
:return: None
"""
self.text = txt
self.label.set_text(txt)

def print(self, *args, sep=" "):
"""
以类似于print函数的方式将若干输入拼接为文本并设置
:param args: 要拼接的输入
:param sep: 间隔符
:return:
"""
self.text = ""
for i in args:
try:
self.text += str(i)
except:
self.text += "<Unknown Element>"
self.text += sep

self.label.set_text(self.text)

·图片组件(位于/lib/ui/ui_images.py)

该部分基于lvgl的img组件,实现了设置自身父组件、设置自身可见性、设置自身位置等方法。

由于lvgl在加载RGB565格式编码的二进制图片时需要手动构建图片描述结构体,而lvgl官方提供的工具生成的RGB565格式的图片又不包含所需的元数据,故我额外搭配了一个可以给二进制图片文件补充元数据的脚本,此图片类则可解析补充的元数据并自动生成lv.img_dsc_t图片描述结构体,大大方便了图片的使用与显示。

import lvgl as lv
import struct

class Image:
"""
对lvgl中图片组件的封装
"""
def __init__(self, src, has_alpha=False, preload=False):
"""

:param src: 要加载的二进制图片的路径
:param has_alpha: 图片是否有alpha通道
:param preload: 是否立刻加载
"""
self.src = src
self.size = (0, 0)
self.data = None
self.has_alpha = has_alpha
self.has_load = False
self.has_show = False
self.align = None
if preload:
d = Image.load_image(src,has_alpha=has_alpha) # 从硬盘或文件系统中中加载图片
if d is not None:
self.size = (d[0], d[1]) # 获取图片尺寸
self.data = d[2] # 获取图片数据

@staticmethod
def load_image(src, style="default", has_alpha=False):
"""
根据指定路径从硬盘或文件系统中加载图片
:param src: 图片文件的路径
:param style: 加载文件的方式,"defalut"或"lv"
:param has_alpha: 图片是否有alpha通道
:return: 图片的数据或None
"""
if style != "lv" and style != "default":
print(f"In function <Image.load_image>: unknown return type style {str(style)}")
return None
try:
with open(src, 'rb') as f:
metadata = f.read(8)
rdata = f.read()

w, h = struct.unpack('>II', metadata)
if style== "default":
return (w, h, rdata)
elif style == "lv":
return lv.img_dsc_t(
{"header":{"always_zero": 0,"w": w,"h": h,"cf": lv.img.CF.TRUE_COLOR_ALPHA if has_alpha else lv.img.CF.TRUE_COLOR},
"data_size": len(rdata),
"data": rdata})
else:
return None
except Exception as e:
print(f"In function <Image.load_image>: Error loading {src}: {e}")
return None

def set(self, parent_scr):
"""
设置图片父组件
:param parent_scr: 父组件
:return: None
"""
self.parent_scr = parent_scr
self.im = img = lv.img(parent_scr)

def load(self, align=None, parent_scr=None, redraw=True):
"""
加载图片
:param align: 图片的位置
:param parent_scr: 父组件
:param redraw: 是否立刻触发屏幕加载
:return: None
"""
if align is None:
align = (lv.ALIGN.CENTER, 0, 0)
if parent_scr is not None:
s = parent_scr
self.im = lv.img(s)
else:
s = self.parent_scr
self.im = lv.img(s)

if self.data is not None:
img_src = lv.img_dsc_t(
{"header":{"always_zero": 0,"w": self.size[0],"h": self.size[1],"cf": lv.img.CF.TRUE_COLOR_ALPHA if self.has_alpha else lv.img.CF.TRUE_COLOR},
"data_size": len(self.data),
"data": self.data}) # 构建lvgl图片描述符
else:
img_src = Image.load_image(self.src, "lv", has_alpha=has_alpha)

self.im.set_src(img_src)
self.im.align(*align)

self.has_load = True
self.has_show = True
self.align = align

if redraw:
lv.scr_load(s)

def toggle_visibility(self):
"""
反转自身的可见性
:return: None
"""
if self.has_show:
self.im.add_flag(lv.obj.FLAG.HIDDEN) # 添加隐藏标志
self.has_show = False
else:
self.im.clear_flag(lv.obj.FLAG.HIDDEN) # 删除隐藏标志
self.has_show = True

def show(self, align=None, parent_scr=None):
"""
设置图片状态为可见
:param align: 图片位置
:param parent_scr: 父组件
:return: None
"""
if align is None:
align = self.align

if not self.has_load:
self.load(align, parent_scr, True)
self.has_load = True
elif not self.has_show:
self.im.clear_flag(lv.obj.FLAG.HIDDEN)
self.has_show = True

def hide(self):
"""
设置图片状态为不可见
:return: None
"""
self.im.add_flag(lv.obj.FLAG.HIDDEN)
self.has_show = False

def delete(self):
"""
删除图片对象
:return: None
"""
self.im.delete()

def __del__(self):
try:
self.delete()
except Exception:
pass

·画布组件(位于/lib/ui/ui_canvas.py)

该部分基于lvgl的canvas组件同时进行了扩展,实现了绘制、清屏、导出画布内容等方法。

由于lvgl的canvas组件使用indev获取触摸坐标比较困难,也没有很好的例程参考,故我直接使用了触摸驱动提供的get_touch方法获取触摸坐标,搭配lv.canvas.set_px方法画点,较为简洁的实现了画布的触摸方法。

import lvgl as lv
from dev.touch_screen import TouchScreenDev as touch

class Canvas:
"""
对lvgl中画布组件的封装与扩展
"""
def __init__(self, x, y, w, h, parent_scr, export_scale_rate = 1):
"""

:param x: 画布的x位置
:param y: 画布的y位置
:param w: 画布的宽
:param h: 画布的高
:param parent_scr: 画布的父组件
:param export_scale_rate: 导出画布内容时应用的缩放率
"""
self.h = h
self.w = w

self.scale = export_scale_rate
# 创建导出缓冲区
self.export_buf_h = int(self.h*self.scale)
self.export_buf_w = int(self.w*self.scale)
self.export_buf = [0]*(self.export_buf_h*self.export_buf_w)

# 创建画布对象
self.cnv = lv.canvas(parent_scr)
self.cnv.set_size(self.w, self.h)
self.cnv.align(lv.ALIGN.DEFAULT, x, y)

# 创建画布缓冲区
self.buf = bytearray(w*h*2)
self.cnv.set_buffer(self.buf, w, h, lv.img.CF.TRUE_COLOR)

self.x = x
self.y = y

def test_xy(self, xy):
"""
测试输入的坐标是否在画布内
:param xy: 待测试的坐标,格式为:(x, y)
:return: 测试结果,是为True,否为False
"""
return (xy[0]>=self.x) and (xy[0]<=self.x+self.w) and (xy[1]>=self.y) and (xy[1]<=self.y+self.h)

def draw_step(self):
"""
更新画布
:return: None
"""
xy = touch.get_touch() # 获取当前触摸的xy坐标
if xy:
# 如果有触摸事件
#print("Touch coord:", xy)
if self.test_xy(xy):
# 如果触摸事件在画布范围内
x = xy[0] - self.x
y = xy[1] - self.y
self.cnv.set_px(x, y, lv.color_hex3(0xFFF)) # 设置相应像素点颜色
ex = min(int(x*self.scale), self.export_buf_w-1)
ey = min(int(y*self.scale), self.export_buf_h-1)
self.export_buf[ey*self.export_buf_w+ex] = 1

def draw_clear(self):
"""
清空画布内容
:return: None
"""
self.cnv.fill_bg(lv.color_hex3(0x000), lv.OPA.COVER)
self.export_buf = [0]*(self.export_buf_h*self.export_buf_w)

def export(self):
"""
导出画布内容
:return: 导出的内容(已扁平化为一维数组)
"""
return self.export_buf

·按钮组件(位于/lib/ui/ui_button.py)

该部分基于lvgl的btn组件,实现了设置按钮文本、设置自身可见性、设置自身位置等方法,并在初始化时提供列表快速注册回调函数的方法。

import lvgl as lv

class Button:
"""
对lvgl中按钮组件的封装
"""
def __init__(self, text, parent_scr, align=None, handler=None):
"""

:param text: 按钮文本
:param parent_scr: 按钮父组件
:param align: 按钮位置
:param handler: 按钮回调函数列表,格式为:[(lvgl事件类型, 回调函数), ]
"""
self.text = text
if align is None:
self.align = (lv.ALIGN.CENTER, 0, 0)
else:
self.align = align
self.parent = parent_scr

# 创建按钮对象
self.btn = lv.btn(parent_scr)
self.btn.align(*self.align)

# 创建按钮上的文本
self.label = lv.label(self.btn)
self.label.set_text(text)

self.hdl_list = []

if handler is not None:
for h in handler:
self.btn.add_event_cb(h[1], h[0], None) # 添加相应事件回调函数
self.hdl_list.append(h)

self.has_show = True

def toggle_visibility(self):
"""
反转自身的可见性
:return: None
"""
if self.has_show:
self.btn.add_flag(lv.obj.FLAG.HIDDEN) # 添加隐藏标志
self.has_show = False
else:
self.btn.clear_flag(lv.obj.FLAG.HIDDEN) # 删除隐藏标志
self.has_show = True

def show(self, align=None, redraw=False):
"""
设置按钮状态为可见
:param align: 按钮位置
:param redraw: 是否立即触发屏幕加载
:return: None
"""
if align is not None:
self.align = align
self.btn.align(*align)

if redraw:
lv.scr_load(self.parent)

if not self.has_show:
self.btn.clear_flag(lv.obj.FLAG.HIDDEN)
self.has_show = True

def hide(self):
"""
设置按钮状态为不可见
:return: None
"""
self.btn.add_flag(lv.obj.FLAG.HIDDEN)
self.has_show = False

def set_text(self, txt):
"""
设置按钮文本
:param txt: 要设置的新文本
:return: None
"""
self.text = txt
self.label.set_text(txt)

d.主程序代码

该部分位于/main_app.py,如需让其可以开机时自动运行,需将其重命名为main.py。

本部分实现了ui的创建、回调函数的定义及主循环里的刷新。

import lvgl as lv
import time

from dev.touch_screen import TouchScreenDev
from dev.wireless_led_panel import WirelessLEDPanelDev
from lib import ui
from lib.MicroNN import MLP


def on_clear(event):
"""
清除画布事件回调函数
:param event: lvgl事件
:return: None
"""
code = event.code
if code == lv.EVENT.CLICKED:
global canvas
image_fox_idea.hide() # 隐藏展示结果的图像
image_fox_normal.show() # 显示默认图像
text3.set_text("") # 清空结果输出字符串
canvas.draw_clear() # 清空画布
return

def on_predict(event):
"""
手写数字识别回调函数
:param event: lvgl事件
:return: None
"""
code = event.code
if code == lv.EVENT.CLICKED:
global canvas
global mlp
global led
image_fox_idea.show() # 显示展示结果的图像
image_fox_normal.hide() # 隐藏默认图像
ans = str(mlp.get_max(mlp.predict(canvas.export()))) # 调用MLP实例进行预测
text3.set_text("You wrote\nnumber "+ans) # 设置结果输出字符串
if led.isconnect:
led.show(ans) # 如果LED灯板在线则将要显示的结果发送给LED灯板控制器
print("Predict:", ans)
return

def on_connect(event):
"""
连接/断开连接LED灯板回调函数
:param event: lvgl事件
:return: None
"""
code = event.code
if code == lv.EVENT.CLICKED:
global led
if not led.isconnect:
led.connect() # 连接LED灯板
else:
led.close() # 断开与LED灯板的连接

if led.isconnect:
text2.set_text("LED Panel: Online") # 设置LED灯板状态字符串
button_connect.set_text("Disconnect") # 设置按钮文本
else:
text2.set_text("LED Panel: Offline") # 设置LED灯板字符串
button_connect.set_text("Connect") # 设置按钮文本
return

# 创建识别手写数字的神经网络
mlp = MLP(input_size=100, hidden_sizes=[20, 20], output_size=10) # 创建多层感知器神经网络
mlp.load_weights("/data/MicroNN/weights.json") # 加载训练好的权重文件

# 创建LED灯板控制器
led = WirelessLEDPanelDev() # 创建LED灯板远程控制器实例

# 创建UI对象
screen = lv.obj() # 创建屏幕对象

# 画布
canvas = ui.Canvas(150, 60, 120, 120, screen, 1/12) # 创建画布对象
# 图片
image_fox_idea = ui.Image("/data/ui/images/fox_idea.bin") # 创建UI图片
image_fox_idea.load((lv.ALIGN.RIGHT_MID, -20, 0), screen, False) # 将图片加载为屏幕的子对象
image_fox_idea.hide() # 设置为隐藏状态
image_fox_normal = ui.Image("/data/ui/images/fox_normal.bin") # 创建UI图片
image_fox_normal.load((lv.ALIGN.RIGHT_MID, -20, 0), screen, False) # 将图片加载为屏幕的子对象
# 按钮
button_clear = ui.Button("Clear", screen, (lv.ALIGN.DEFAULT, 0, 55), [(lv.EVENT.ALL, on_clear)]) # 创建清空画布按钮
button_predict = ui.Button("Predict", screen, (lv.ALIGN.DEFAULT, 0, 105), [(lv.EVENT.ALL, on_predict)]) # 创建预测按钮
button_connect = ui.Button("Connect", screen, (lv.ALIGN.DEFAULT, 0, 155), [(lv.EVENT.ALL, on_connect)]) # 创建连接LED灯板按钮
# 文本
text1 = ui.Text("ESP32 Handwritten Digit Recognition by Mr.Wolf", screen, (lv.ALIGN.DEFAULT, 0, 0))
text2 = ui.Text("LED Panel: Offline", screen, (lv.ALIGN.DEFAULT, 0, 20))
text3 = ui.Text("", screen, (lv.ALIGN.RIGHT_MID, 0, -100))
text_help1 = ui.Text("Press 'Clear' to clean up the canvas", screen, (lv.ALIGN.BOTTOM_LEFT, 0, -40))
text_help2 = ui.Text("Press 'Predict' to get the result", screen, (lv.ALIGN.BOTTOM_LEFT, 0, -20))
text_help3 = ui.Text("Press 'Connect/Disconnect' to connect/disconnect the LED panel", screen, (lv.ALIGN.BOTTOM_LEFT, 0, 0))

# 加载UI
lv.scr_load(screen) # 加载至屏幕上

# 主循环
while True:
lv.timer_handler() # 执行lvgl时间片轮询
lv.tick_inc(5) # 告知lvgl时钟滴答
canvas.draw_step() # 画布更新
time.sleep_ms(5) # 暂停一段事件防止系统资源消耗过快

②LED灯板部分

该部分作为此次手写识别显示任务的结果显示部分,共包含主程序文件、8*8点阵字库文件以及驱动设备类文件,下面将进一步对其进行介绍。

a.LED灯板驱动类(位于/dev/led_panel.py)

该灯板使用两个74HC595进行级联控制,我这里采用的是先发送行号再发送行数据的行扫描的方法进行字符的显示。

(注:若将该驱动类拷贝至CrowPanel ESP32开发板的/dev文件夹下并适当修改主程序代码也可实现CrowPanel ESP32开发板有线控制LED灯板)

from machine import Pin

class LEDPanelDev:
"""
LED灯板设备类
"""
def __init__(self, din_pin=41, srclk_pin=21, rclk_pin=18):
"""

:param din_pin: 连接到灯板“->D”的引脚
:param srclk_pin: 连接到灯板“SRCLK”的引脚
:param rclk_pin: 连接到灯板“RCLK”的引脚
"""
self.din = Pin(din_pin, Pin.OUT)
self.srclk = Pin(srclk_pin, Pin.OUT)
self.rclk = Pin(rclk_pin, Pin.OUT)

self.din.value(0)
self.srclk.value(0)
self.rclk.value(0)

self.rows = 8
self.cols = 8
self.buffer = [0] * self.rows # 每行的LED状态

def _shift_out(self, data):
"""
将数据移位到74HC595
:param data: 要发送的数据
:return: None
"""
for bit in range(8):
self.din.value((data >> (self.cols-bit-1)) & 1)
self.srclk.value(1)
self.srclk.value(0)

def _latch(self):
"""
将移位寄存器的数据锁存到存储寄存器
:return: None
"""
self.rclk.value(1)
self.rclk.value(0)

def set_led(self, row, col, state):
"""
设置指定LED的状态
:param row: 要设置LED的行
:param col: 要设置LED的列
:param state: 要设置LED的状态
:return: None
"""
if row < 0 or row >= self.rows or col < 0 or col >= self.cols:
raise ValueError("Row or column out of range")

row = row-1
col = col-1

if state:
self.buffer[row] |= (1 << col)
else:
self.buffer[row] &= ~(1 << col)

def set(self, data):
"""
设置所有LED的状态
:param data: 要设置LED的状态,应该是一个有8个元素的列表
:return: None
"""
self.buffer = data

def update(self):
"""
更新LED矩阵
:return: None
"""
for row in range(self.rows):
# 按行扫描
rdata = 2**row
self._shift_out(rdata) # 设置对应的行号
self._shift_out(self.buffer[row]) # 设置该行LED的状态
self._latch() # 将移位寄存器的数据锁存到存储寄存器

def clear(self):
"""
清除所有LED
:return: None
"""
self.buffer = [0] * self.rows
self.update()

b.主程序代码

该部分位于/main.py,可开机时自动运行。

本部分实现了无线接入点的创建、TCP服务端的定义,主循环则实现了LED灯板的刷新和命令控制状态机。

import network
import socket

from data.fonts import font8x8 as f
from dev.led_panel import LEDPanelDev


led_matrix = LEDPanelDev() # 创建LED灯板对象

# 创建AP作为服务器
ap = network.WLAN(network.AP_IF)
ap.config(essid='LED_Panel', password='matrix64', channel=6, authmode=network.AUTH_WPA2_PSK)
ap.active(True)
print(ap.ifconfig())

# 创建套接字并监听客户机的连接
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("192.168.4.1", 5000))
s.listen(1)
s.setblocking(False)

cmd = ("waiting", 0) # 当前命令
# 可用命令分别为:("waiting", 0), ("idle", 0), ("set", char), ("clear", 0), ("quit", 0)
tim = 0 # 计时器
timeout = 20000 # 超时阈值
addr = None
conn = None


def server():
"""
接收来自客户机的信息并设置相应执行命令
:return:
"""
global cmd
global conn
global addr
try:
msg = conn.recv(128)
if msg:
m = msg.decode()
print(f"{addr}: {m}")
if m.startswith(":char"):
cmd = ("set", m[6])
elif m.startswith(":clear"):
cmd = ("clear", 0)
elif m.startswith(":quit"):
cmd = ("quit", 0)
else:
print("Unknown command:", m)
except:
pass

i = 0
# 主循环
while True:
#print(cmd)
if cmd[0] == "waiting":
# 等待连接状态
if i%100 == 0:
led_matrix.set(f.char[f"con{(int(i/100))%4+1}"]) # 设置显示图案
i += 1
flag = True
try:
conn, addr = s.accept() # 监听来自客户机的连接请求
except OSError as e:
flag = False
if flag:
conn.settimeout(0)
led_matrix.clear() # 清空灯板内容
cmd = ("idle", 0)
elif cmd[0] == "set":
# 设置显示字符状态
i = 0
if cmd[1] in f.char:
led_matrix.set(f.char[cmd[1]]) # 设置相应字符
else:
led_maxtrix.set(f.char["unknown"])
cmd = ("idle", 0)
elif cmd[0] == "clear":
# 清屏状态
i = 0
led_matrix.clear()
cmd = ("idle", 0)
elif cmd[0] == "quit":
# 断开连接状态
conn.close()
cmd = ("waiting", 0)
elif cmd[0] == "idle":
# 等待命令状态
i = 0
tim += 1
led_matrix.update()
else:
raise ValueError(f"Unknown command: {cmd[0]}")

if tim > timeout:
# 超时则自动断开连接
conn.close()
cmd = ("waiting", 0)
tim = 0

led_matrix.update() # 灯板更新
if cmd[0] != "waiting":
server() # 尝试获取客户机发送的指令

③电脑部分脚本

主要包括手写数字采集脚本、图片元数据插入脚本与神经网络训练脚本,因为这些都不算核心代码故不在此处赘述,感兴趣的话可以在附件中查看。

六、遇到的困难及解决方法

1.神经网络使用问题

问题描述:

现有的支持微控制器的神经网络大多都需要使用C/C++进行开发,为数不多支持Micropython的神经网络大多都需要专有硬件或需要被预先编译进固件里,而这边官方提供的固件并不包含类似的库;而且此开发板仅有512KB的RAM,资源比较受限。

解决方法:

我采用了自行编写的一个神经网络模型,该模型仅使用Micropython通用库,故可以很方便的移植;而且如果将引用的Micropython库换成Python标准库的话,该神经网络也可被移植到电脑上,故可以利用电脑的高算力对神经网络进行训练,之后再将训练好的权重文件移植到Micropython设备中。

该神经网络仅有215行,用于手写数字识别的权重文件仅有54KB。神经网络部分包含完整的训练方法、预测方法、模型评估方法、数据洗牌算法、激活函数及其导数、权重的保存与加载方法,其中训练方法还包括误差的反向传播。

2.内存分配问题

问题描述:

由于lvgl+神经网络系统开销过大,内存消耗过快,可能是因为Micropython内部的某个内存分配相关的小问题,导致屏幕绘制相关的数据可能在运行一段时间后被污染,导致屏幕卡住没法正常刷新(但是并不会触发任何可以捕获的错误)。

解决方法(临时):

此问题仅导致屏幕卡住,其余部分仍可正常工作,用户还可正常使用别的功能(包括手写数字的采集、识别与控制灯板显示等),用户可在方便时再按板子上的reset按键进行重启以恢复。

3.lvgl画布问题

问题描述:

因为此次提供的固件中lvgl库函数和网上主流教程中使用的有出入,导致无法按照网上教程给出的方法使用画布的indev获取触摸坐标。

解决方法:

直接使用xpt触摸驱动的get_touch在主循环中获取触摸事件的坐标,先判断该坐标是否在画布内,再搭配lvgl画布的set_px方法将画布内的触摸事件的坐标按点绘制到画布组件上。

七、心得和体会

通过本次实验,我成功实现了基于CrowPanel ESP32 Display 4.3英寸HMI开发板的手写识别显示任务。通过本次活动,我深度学习了Micropython+lvgl的GUI开发和神经网络的基础理论与开发训练,并积攒了宝贵的经验。同时,我要感谢硬禾学堂为我提供这次机会。

至于建议,我希望有条件的话可以将CrowPanel ESP32 Display 4.3英寸HMI开发板板载主控芯片由ESP32-S3-WROOM-1-N4R2升级为ESP32-S3-WROOM-1-N16R8,以便为端侧AI的应用提供更强大的性能与更多可能性。

八、未来的展望

尽管本项目已经成功实现了基于CrowPanel ESP32 Display 4.3英寸HMI开发板的手写识别显示任务,并达到了预期指标。然而还有许多可以提升与扩展的地方:

  1. 目前手写识别的多层感知机神经网络模型还不太稳定,正确率忽高忽低,未来希望采集更多的手写数字集进行训练以增加该模型的鲁棒性。
  2. 深度排查潜在的内存问题,找到导致屏幕潜在卡死风险的漏洞并修复之。
  3. 扩展LED灯板的显示字库与显示动画,并增加其他外部设备与CrowPanel ESP32 Display 4.3英寸HMI开发板配合构建更完整的物联网体系。
  4. CrowPanel ESP32 Display 4.3英寸HMI开发板的UI增加动画并扩展功能。
软硬件
元器件
74HC595
8位移位寄存器 8位输出寄存器
ESP32-S2-MINI-1
ESP32-S2-MINI-1 是通用型 Wi-Fi MCU 模组,功能强大,具有丰富的外设接口,可用于可穿戴电子设备、智能家居等场景。该模组内置 ESP32-S2FH4 / ESP32-S2FN4R2 芯片,芯片搭载 Xtensa® 32 位 LX7 单核处理器,工作频率高达 240 MHz。
ESP32-S3-WROOM-1
ESP32-S3-WROOM-1 是通用型 Wi-Fi + 蓝牙 MCU 模组,具有丰富的外设接口,强大的神经网络运算能力和信号处理能力,是专为人工智能和 AIoT 市场打造的两款模组,适用于多种应用场景, 例如唤醒词检测和语音命令识别、人脸检测和识别、智能家居、智能家电、智能控制面板、智能扬声器等。
附件下载
Firmwares.rar
项目使用的Micropython固件(包括CrowPanel部分固件和LED灯板部分固件)
ESP32_Handwritten_Digit_Recognition_project.rar
项目的完整源代码(包括CrowPanel部分代码、LED灯板部分代码和电脑脚本部分代码)
团队介绍
华中科技大学
团队成员
Mr.Wolf
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号