基于ESP32-S3的手写数字识别系统项目报告
首先是一些感想,很早就参加了本次活动,但是寒假忙小论文再加上拖延症发作,一直到最后才是整本次活动的内容。以前有ST系列单片机的经验,但接触esp32s3还是第一次。整个开发过程中遇到了许许多多古怪的bug,可能也有自己容易钻牛角尖的原因,经常一个问题卡一下午得不到进展。最让我记忆深刻的屏幕闪烁的问题,因为esp内部ram有效,就需要分配lvgl的屏幕缓冲区到外部SPIRAM,但是分配以后,不触摸屏幕则显示正常。一旦触摸屏幕,整个屏幕就会快速闪烁。我找找了方方面面各种各样的原因,尝试了各种不同的刷新和缓存方式,但是最终问题出现的原因是在SDK Config中把相关Catch缓存设置好就能解决。种种“心酸”就不一一列举了。 可惜因为拖延症,在最后一天才勉强完成任务,而且项目还有很多可以完善优化的地方。但是作为从零开始接触ESP32S3,又是在较短的时间里高强度的学习、调试,我真切感到自己有了很多收获。期待下次的训练营活动。
(以下正文开始)
然后对了在神经网络部署部分由于实在没时间整tensflow的环境了 所以采用了纯C代码,自己在ESP32s3上做了适配 通过挂载挂载SPIFFS文件系统读取网络模型参数。然后优化RAM使用空间折腾了好久好久!(需要更大的RAM)
以下是我参考的纯C神经网络代码链接:
https://gitee.com/li_ximing/CNNhandWritenRecognition
项目介绍和硬件资源
本项目基于ESP32-S3微控制器实现嵌入式手写数字识别系统,通过触摸屏采集用户输入的手写数字图像,利用轻量化神经网络进行实时推理识别。使用LVGL编程在LCD屏幕上设定一个正方形的写字区域在写字区域里书写0-9的数字对书写的数广进行识别,并将识别的数字传递给灯板,在灯板上进行显示(灯板信息)
硬件模块 型号参数 功能说明
主控芯片 ESP32-S3-WROOM-1-N4R2 双核240MHz,4MB Flash 2MB SPIRAM(为什么不多给我点RAM?)
屏幕:TFT-LCD屏幕,分辨率:480*272,触摸类型:电阻式触摸屏,屏幕驱动:NV3047其他接口:1*TF卡槽,2*GPIO, 1*Speak, 2*UART1, 1*UARTO
在esp32S3上完成手写数字识别后,还需要点亮LED灯矩阵。LED灯矩阵的相关信息如下。
资源:8*8共64颗单色LED灯,封装大小~0603
两颗串-并变换、SOIC-16封装的74HC595D、2颗0603封装的电源去耦电容。
方案框图和项目设计思路
(1) 首先在ui.screen中初始画布控件,然后因为200*200像素占用的空间比较大,所以要把它初始化到外部SPIRAM上。
刚开始我想当然的想法时,可以不可以给画布绑定一个触摸事件,然后只有在触摸画布区域的时候可以开始进行手写。然后捣鼓了好久,发现只能触发“点击”事件,但是持续按压的情况下无法跳转到画布绑定的事件。默认是到在main中初始化绑定的my_touchpad_read中了。因为自己是第一次接触lvgl,折腾了半天没有成功,时间又很有限只能退而求其次在my_touchpad_read函数中通过判断屏幕区域来进行手写数字识别。
通过电阻式触摸笔在屏幕的数字手写区域输入0-9任意一个数字,屏幕对应显示手写数字,并将200*200的手写区域内图像压缩保存为28*28的二值化图像数据;
(2) 点击识别按钮后,首先会对屏幕区域的手写数字进行处理,最后是一个30*30的二值化图像数据按照相应格式输入已经训练好的神经网络中,经过计算后,输出识别的数字,并通过LED点阵显示;
(3) 点击清除按钮,清除屏幕上的手写数据。
关键代码和流程图
首先抱歉了,时间紧张流程图画的简陋,然后我多介绍一点重要的函数。
简单的初始化部分略过,没什么价值。
首先是这三个函数
free_8bit = esp_get_free_internal_heap_size(); //内部RAM
free_spiram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); // PSRAM
min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_8BIT); //历史最小剩余内存
负责来实时监控ESP32S3的RAM使用情况。因为是第一次接触 我这次活动里在RAM分配上花费的时间非常多。后面就养成每分配一次较大的空间就查看一下RAM的使用情况。
然后就是load_network_params()函数,它负责加载SPIFFS文件系统,并从.txt文件中加载训练好的神经网络模型参数。
如图所示,为部分加载代码。代码里有几个需要尤其关注的点,比如这里
//添加内存分配校验
if (!g_network_params) {
ESP_LOGE(TAG, "PSRAM分配失败!可用内存: %d",
heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
// ...错误处理...
}
每次分配完内存以后,一定一定要检查下是否分配成功。我有好多次都是RAM用完了,没有分配成功足够大的空间,导致后续使用的时候会报内存越界。
另外还有即使内存分配成功了 如果不修改esp-idf的优化选项 已分配的大块内存空间后续可能会被占用,我有遇到过从在分配后检验读取的网络参数是正确的,但是在初始化后的UI回调函数中再次检查发现内存被破坏了。
后续我写了四个检查函数,来分别打印神经网络各个层的参数来确保读取成功。
最后就是在触摸读取函数
static lv_point_t points_array[3000]; // 存储触摸轨迹
static uint16_t point_count = 0;
#define BRUSH_RADIUS 8
void my_touchpad_read(lv_indev_drv_t *indev_driver, lv_indev_data_t *data) {
static lv_point_t last_point = {0, 0};
static bool is_touching2 = false;
const uint16_t dead_zone = 1;
// static lv_point_t prev_point = {0};
// 1. 直接读取触摸状态,不依赖时间/空间过滤
bool hardware_touched = touch_touched();
if (hardware_touched) {
lv_point_t current_point = {(short)touch_last_x, (short)touch_last_y};
// 2. 仅过滤微小移动,但不影响状态机
if (abs(current_point.x - last_point.x) > dead_zone ||
abs(current_point.y - last_point.y) > dead_zone) {
last_point = current_point;
}
data->point = last_point;
//data->state = LV_INDEV_STATE_PR; // 只有硬件触摸,状态保持 PRESSED
data->state = LV_INDEV_STATE_PRESSED; // 只要硬件触摸,状态保持 PRESSED
is_touching2 = true;
if(((150<data->point.x)&&(data->point.x<350))&&((50<data->point.y)&&(data->point.y<250)))
{
lv_point_t curr_point = {
.x =(short int) (data->point.x - lv_obj_get_x(canvas)),
.y = (short int)(data->point.y - lv_obj_get_y(canvas))
};
//Serial.print("运行到这里了");
lv_draw_line_dsc_t line_dsc;
lv_draw_line_dsc_init(&line_dsc);
line_dsc.color = lv_color_white();
line_dsc.width = 8;
// 存储轨迹点
if(point_count < sizeof(points_array)/sizeof(points_array[0])) {
points_array[point_count++] = curr_point;
}
// raw_bitmap[curr_point.x][curr_point.y] = 1;
// for (int a = 0; a< 10; a++) {
// raw_bitmap[curr_point.x+a][curr_point.y+a] = 1;
// }
// for (int b = 0; b< 10; b++){
// raw_bitmap[curr_point.x-b][curr_point.y-b] = 1;
// }
for (int dx = -BRUSH_RADIUS; dx <= BRUSH_RADIUS; dx++) {
for (int dy = -BRUSH_RADIUS; dy <= BRUSH_RADIUS; dy++) {
// 计算当前点坐标
int x = curr_point.x + dx;
int y = curr_point.y + dy;
// 检查边界
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) continue;
// 圆形判断(仅填充圆形区域内点)
if (dx*dx + dy*dy <= BRUSH_RADIUS * BRUSH_RADIUS) {
raw_bitmap[x][y] = 1; // 设置像素
}
}
}
// ESP_LOGW(TAG, "ESP_LOGW test!\n");
// lv_color_t c = lv_color_hex(0x123456);
// // 绘制线段
// for(int tmepi=0;tmepi<point_count;tmepi++)
// {
// lv_canvas_set_px(canvas, curr_point.x, curr_point.y, c);
// }
// ESP_LOGW(TAG, "持续按压中\n");
lv_canvas_draw_line(canvas, points_array, point_count, &line_dsc);
//prev_point = curr_point;
//prev_point = curr_point;
//lv_obj_invalidate(canvas);
}
} else {
data->state = LV_INDEV_STATE_RELEASED;
is_touching2 = false;
lv_obj_invalidate(canvas);
int temp2;
for(temp2 = 0;temp2<point_count+2;temp2++)
{
points_array[temp2].x = 0;
points_array[temp2].y = 0;
}
point_count=0;
//Serial.println("hardware_touched是错误状态");
}
// 局部刷新优化
// lv_area_t area;
// lv_area_set(&area,
// prev_point.x - 8, prev_point.y - 8,
// canvas_point.x + 8, canvas_point.y + 8);
// lv_obj_invalidate_area(canvas, &area);
// 调试输出
// Serial.printf("Touch: x=%d, y=%d, state=%s\n",
// data->point.x,
// data->point.y,
// data->state == LV_INDEV_STATE_PRESSED ? "PRESSED" : "RELEASED");
// Serial.printf("Touch: x=%d, y=%d, state=%s\n",
// curr_point.x,
// curr_point.y,
// data->state == LV_INDEV_STATE_PRESSED ? "PRESSED" : "RELEASED");
}
手写数字对触摸灵敏度要求不太高,设置时间死区 有时候反而会有断触的风险。我就只做了空间上的消抖处理,当移动范围超过设定的像素点时,esp32s3才会记录触点信息。默认判断只要保持按压状态,则始终会读取数据。canvs画布上的笔画效果,用lvgl中的画笔控件和draw_line接口就可以实现。然后在触摸区域数据读取上通过“画圆” 模拟画笔的效果。
for (int dx = -BRUSH_RADIUS; dx <= BRUSH_RADIUS; dx++) {
for (int dy = -BRUSH_RADIUS; dy <= BRUSH_RADIUS; dy++) {
// 计算当前点坐标
int x = curr_point.x + dx;
int y = curr_point.y + dy;
// 检查边界
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) continue;
// 圆形判断(仅填充圆形区域内点)
if (dx*dx + dy*dy <= BRUSH_RADIUS * BRUSH_RADIUS) {
raw_bitmap[x][y] = 1; // 设置像素
}
}
}
总结和硬件展示
在画布区域进行触摸手写数字,在点击begin按钮控件后,led灯屏会显示识别结果,led灯屏2s后会自动熄灭
同时通过串口也会打印识别结果和”图案“。 如图上画×的区域是有笔记的区域,.是空白区域,这一部分是我之前调试的时让它打印的,把输入给神经网络模型的数据通过串口打印出来,可以最后校验一下输入数据有没有问题。
最后是感谢部分,其实前面在代码部分阐述问题时,也断断续续讲了很多了,时间比较紧张又是初次接触,最后的效果不太好,0-9十个数字只有5个能正常识别。一部分是因为网络结构不太好,全连接层占用的参数量比较大;另一部分是为节省RAM空间做了从double到float的量化,手法也很粗暴,是直接截取的。最终导致精度不太理想。
但是总体上,我觉得收获非常大,感谢平台举行的此次活动。下次我会继续参加(下次肯定留足时间,效果肯定比这次好!)