1. 项目介绍
- 使用LVGL编程
- 在LCD屏幕上设定一个正方形的写字区域
- 在写字区域里书写0-9的数字
- 对书写的数字进行识别,并将识别的数字传递给灯板,在灯板上进行显示(灯板信息)
2.硬件介绍
2.1 CrowPanel ESP32 Display 4.3英寸HMI开发板
主处理器:ESP32-S3-WROOM-1-N4R2,主频:240MHz, Flash:4MB, PSRAM:2MB
屏幕:TFT-LCD屏幕,分辨率:480*272,触摸类型:电阻式触摸屏,屏幕驱动:NV3047
其他接口:1*TF卡槽,2*GPIO,1*Speak,2*UART1,1*UART0
2.2 LED点阵灯板
资源:8*8共64颗单色LED灯,封装大小0603;8个NPN三极管9013和16个0603封装的电阻,用于驱动LED阵列,给每排8个LED提供点亮所需要的电流;两颗级联的74HC595D;2颗0603封装的电源去耦电容;2个5管脚直插的连接器用于与ESP32开发板对接。
ESP32开发板和灯板连接说明:LED灯板VCC和GND连接ESP32开发板的3V3和GND,->D连接IO17,SRCLR、RCLK分别连接IO37、38。
74HC595管脚说明:
14脚:SER,串行数据输入引脚。
13脚:OE,输出使能控制脚,低电平有效。
12脚:RCLK,存储寄存器时钟输入引脚。上升沿时,数据从移位寄存器转存到存储寄存器。
11脚:SRCLK,移位寄存器时钟引脚,上升沿时,移位寄存器中的bit 数据整体后移,并接受新的bit(从SER输入)。
10脚:SRCLR,低电平时,清空移位寄存器中已有的bit数据,一般不用,接高电平即可。
9 脚 :串行数据出口引脚。当移位寄存器中的数据多于8bit时,会把已有的bit“挤出去”,就是从这里出去的。用于595的级联。
QA~QH:并行输出引脚。
3.方案框图和项目设计思路介绍
(1)通过电阻式触摸笔在屏幕的数字手写区域输入0~9任意一个数字,屏幕对应显示手写数字,并将200*200的手写区域内图像压缩保存为28*28的二值化图像数据;
(2)点击识别按钮,将28*28的二值化图像数据按照相应格式输入已经训练好的神经网络中,经过计算后,输出识别的数字,并通过LED点阵显示;
(3)点击清除按钮,清除屏幕上的手写数据。
4.软件流程图和关键代码介绍
4.1 软件流程图
4.2 触摸回调函数
该函数作为LVGL输入设备的核心回调函数,负责处理触摸屏输入事件并实现数字书写区域的实时交互功能。
当检测到有效触摸信号时,它会持续跟踪用户的书写动作:首先通过判断坐标变动阈值(≥20像素)识别新笔画的开始,随后将触摸坐标映射到200×200的数字书写区域内显示,并转换为对应的28×28像素矩阵索引。
该函数利用LVGL画布的双端点连线功能实现流畅的轨迹绘制,并在检测到触摸离开识别区域时重置绘制状态,最终通过全局变量同步更新显示界面和神经网络输入数据,完成从手写输入到数字识别的完整链路处理。同时,在绘制过程中,通过3×3邻域填充算法模拟线条粗细,将用户绘制的轨迹实时转化为MNIST数据集的1维28*28矩阵。
void my_touchpad_read(lv_indev_drv_t *indev_driver, lv_indev_data_t *data)
{
if (touch_has_signal())
{
if (touch_touched())
{
data->state = LV_INDEV_STATE_PR;
/*Set the coordinates*/
// 【坐标处理】检测是否完成长距离移动(视为新笔画开始)
if((data->point.x - touch_last_x >=20 || touch_last_x - data->point.x >=20)
&& (data->point.y - touch_last_y >= 20 || touch_last_y - data->point.y >= 20))
{
points[1] = {-1,-1};
}
data->point.x = touch_last_x;
data->point.y = touch_last_y;
// 【区域限制】只在数字识别区域内响应
if(data->point.x > 140 && data->point.x < 340 && data->point.y > 36 && data->point.y < 236)
{
// 绘制逻辑
points[0] = points[1];
points[1] = {(short)(data->point.x - 140), (short)(data->point.y - 36)};
// 200*200压缩至28*28 像素填充:在3x3邻域设置1(模拟线条粗细)
for(int i=-1; i<=1; i++)
{
for(int j=-1; j<=1; j++)
{
mnist[(int)((points[1].y*28/200+i)*28 + points[1].x*28/200+j)] = 1;
}
}
// 绘制线段
if(points[0].x != -1)
{
lv_canvas_draw_line(write_area, points, 2, &line_dsc); // 绘制线条,传入点和样式
}
}
else
{
// 离开识别区域时重置绘制
points[1] = {-1,-1};
}
}
}
else
{
data->state = LV_INDEV_STATE_REL;
}
}
4.3 按钮回调函数
该函数作为LVGL事件驱动的核心回调,负责响应界面按钮点击事件并执行相应操作。
当识别按钮(btn1)被触发时,调用Eloquent TinyML框架对当前手写数字矩阵进行推理,通过遍历预测概率数组确定最高置信度的分类结果,并更新显示标签和预测标志位以触发数码管显示;
当清空按钮(btn2)被点击时,通过重置画布背景、清除像素矩阵数据和双端点坐标来实现书写区域的清空,同步更新界面状态和输入数据。函数通过全局变量(pred_num/pred_flag/mnist)与显示模块、模型推理模块和数码管驱动模块进行状态同步,利用LVGL的异步事件机制确保UI交互的实时性和流畅性,同时要求模型推理耗时需控制在UI刷新周期内以避免卡顿。
void (*p_my_gui)(void);
static void my_event_cb(lv_event_t * e)
{
lv_obj_t *target = lv_event_get_target(e);
if(target == btn1)
{
/* 检验压缩的数据是否正确 */
// for(int i=0; i<28*28; i++)
// {
// if (i%28==0)
// {
// Serial.printf("\r\n");
// }
// Serial.printf("%d ", mnist[i]);
// }
// 【模型推理】使用当前输入数据进行预测
tf.predict(mnist, predicted);
// 【结果解析】寻找最大概率的分类结果
float max = -100;
for(int i=0; i<10; i++)
if(predicted[i]>=max)
pred_num = i, max = predicted[i];
// 【界面更新】显示识别结果
lv_label_set_text_fmt(label_num, "NUM: %d", pred_num);
pred_flag = 1;
}
else if(target == btn2)
{
// 【界面重置】清除数字书写区域
lv_canvas_fill_bg(write_area, lv_color_white(), LV_OPA_COVER);
// 【数据重置】初始化输入矩阵和绘制状态
points[1] = {-1,-1};
memset(mnist, 0, sizeof(mnist));
// 【界面更新】显示清空状态
lv_label_set_text_fmt(label_num, "NUM:");
pred_flag = 0;
}
}
4.4 74HC595驱动点阵屏
该函数通过HC595芯片向LED点阵发送指定数字的显示数据。
它从预定义的段码表hc595_num中读取数字对应的二进制段码,通过两次HC595_Send_Byte配合锁存操作,逐行点亮显示设备上的对应段组,最终实现目标数字的动态显示。
/* 发送数字 */
void HC595_Send_Num(uint8_t num)
{
uint8_t i;
uint8_t j;
uint8_t raw = 0x01;
uint8_t c = 0;
for(i = 0; i < 8; i++)
{
c = hc595_num[num][i];
HC595_Send_Byte(raw);
HC595_Send_Byte(c);
HC595_Save();
raw <<= 1;
}
}
下面是根据8*8的像素矩阵,提前预设好数字0~9以及不显示数字时,控制行显示的HC595芯片8位存储寄存器的输出。
const uint8_t hc595_num[11][8] = {
{0x00, 0x3C, 0x66, 0x42, 0x42, 0x42, 0x66, 0x3C},//0
{0x10, 0x18, 0x14, 0x10, 0x10, 0x10, 0x10, 0x7E},//1
{0x00, 0x7E, 0x40, 0x40, 0x7E, 0x02, 0x02, 0x7E},//2
{0x00, 0x7E, 0x40, 0x40, 0x7E, 0x40, 0x40, 0x7E},//3
{0x18, 0x14, 0x12, 0x11, 0xFF, 0x10, 0x10, 0x10},//4
{0x00, 0x7E, 0x02, 0x02, 0x7E, 0x40, 0x40, 0x7E},//5
{0x00, 0x7E, 0x02, 0x02, 0x7E, 0x42, 0x42, 0x7E},//6
{0x7E, 0x40, 0x20, 0x20, 0x10, 0x10, 0x08, 0x08},//7
{0x00, 0x7E, 0x42, 0x42, 0x7E, 0x42, 0x42, 0x7E},//8
{0x00, 0x7E, 0x42, 0x42, 0x7E, 0x40, 0x40, 0x7E},//9
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}//不显示
};
4.5 神经网络
通过Python,基于Keras框架构建了一个神经网络模型结构如上图所示,考虑到要在esp32上部署,所以使用了简单的结构,使用MNIST手写数字数据集进行训练,最后可以达到98%的准确率,将训练好的模型保存为.h5文件。通过tinymlgen库的port函数将.h5文件转化为.h文件。
#ifdef __has_attribute
#define HAVE_ATTRIBUTE(x) __has_attribute(x)
#else
#define HAVE_ATTRIBUTE(x) 0
#endif
#if HAVE_ATTRIBUTE(aligned) || (defined(__GNUC__) && !defined(__clang__))
#define DATA_ALIGN_ATTRIBUTE __attribute__((aligned(4)))
#else
#define DATA_ALIGN_ATTRIBUTE
#endif
const unsigned char model_data[] DATA_ALIGN_ATTRIBUTE = {0x1c, 0x00, ...... 0x00, 0x03};//此处省略
const int model_data_len = 6664;
5.功能展示图及说明
功能说明
功能展示图(成功识别“6”)
6. 项目中遇到的难题和解决方法
6.1 触摸区域坐标映射与书写连贯性
问题:电阻式触摸屏存在灵敏度差异,手写时的小幅移动易产生误判,且200×200区域需压缩为28×28像素时可能出现线条断裂或粗细不均。
解决方案:引入移动距离阈值(≥20像素)判断新笔画开始,避免噪声干扰。 采用3×3邻域填充算法模拟粗细,平衡线条连续性与识别精度。 在LVGL中启用双端点连线功能,通过lv_canvas_draw_line()实现平滑轨迹绘制。
6.2 神经网络模型在嵌入式端的部署
问题:嵌入式设备的Flash和PSRAM容量有限,传统模型占用内存过大。模型训练框架(如Keras)与嵌入式推理框架(如TensorFlow Lite)之间的格式转换问题
解决方案:对神经网络模型重新设计,使其结构更加简单,权重参数数量更加精简。通过tinymlgen库的port函数将模型文件转化成C语言文件,配合Eloquent TinyML库使用训练好的模型。
7.对本次活动的心得体会
7.1 项目收获
嵌入式GUI开发:掌握了LVGL的事件驱动机制与硬件抽象层设计,理解了电阻式触摸屏的坐标转换逻辑。
边缘AI落地:通过Keras-TinyML框架实现了轻量化模型训练与部署,深刻体会到模型压缩对嵌入式端的重要性。
硬件驱动调试:成功解决了HC595级联控制中的时序问题,提升了LED显示的可靠性。
7.2 改进建议
模型优化: 引入迁移学习(如基于MNIST预训练的CNN模型),提升复杂手写数字的识别率。增加数据增强(旋转、缩放)训练,增强模型的泛化能力。
硬件扩展: 优化LED驱动电路,采用MOSFET替代三极管以降低功耗。
软件架构: 将全局变量替换为消息队列(如FreeRTOS的xQueueSend),提升多任务并发稳定性。 增加错误处理机制(如SPI通信失败重试、模型加载异常检测)。
用户体验: 添加书写实时反馈(如当前笔画粗细调节、数字轮廓显示)。 设计多语言支持(如中文数字标签),扩大应用场景。