项目介绍
本项目基于 CrowPanel ESP32 Display 4.3 英寸 HMI 开发板,实现手写数字识别功能。通过驱动触摸屏,利用 LVGL 轻量级通用型图形库实现画板、按键等功能,可在画板上绘制 0 至 9 的数字,点击识别按钮后,能对所画数字进行识别,并将识别结果输出至 8*8 矩阵灯板上,同时本项目也实现了手写计算器,能够较好的进行个位数的加、减、乘运算。
硬件介绍
Elecrow ESP32 4.3英寸显示屏是一款功能强大的HMI触摸屏,具有480*272分辨率的LCD显示屏。它使用ESP32-S3-WROOM-1-N4R2模组作为主控处理器,具有双核32位 LX7处理器,集成WiFi和蓝牙无线功能,主频高达240MHz,提供强大的性能和多功能的应用,适用于物联网应用设备等场景。
方案框图和项目设计思路
该项目的设计思路我分为三个部分,分别是使用LVGL实现画板功能,使用ESP32实现手写识别,将结果显示到8*8LED点阵上。
LVGL实现画板功能
画板功能准备先读取触摸屏的输入,然后判断输入的坐标是否在画板的区域内,若在画板区域内则将第一次点击的坐标记录下来,并且存入到数组中,当第二次点击时,把该坐标也存入数组中,同时调用LVGL内置的画直线函数,在画板上画出一段直线,由于记录两次点击的间隔很短,在屏幕上体现则是一段很短的线段,将各个线段连接起来,则构成了书写的数字。
ESP32S3实现手写数字识别
手写数字识别部分由于每个人的书写习惯以及写字大小形状均不相同,因此一些特定的逻辑判定很难满足本项目的要求,经查阅资料后发现深度学习里的一些算法可以较好的满足本项目的要求,所有本次项目准备使用深度学习里的卷积神经网络(LeNet)来进行手写数字的识别。具体实现思路为使用开源数据集,然后对数据集进行预处理,再然后对模型进行训练,再将模型进行一定的量化,最后部署到ESP32上。
8x8LED点阵显示数字
该部分使用了2片595进行驱动8x8的LED点阵,首先确定好扫描方式,然后根据扫描方式确定好LED的输出字码,最后封装好函数,每次显示特定的数字,直接调用接口函数即可。通过控制3个引脚实现8x8点阵的动态扫描,包含自己设计的数字0-9的压缩字库,采用行扫描方式以1ms刷新周期循环显示,确保识别结果的可视化反馈实时可靠。
软件流程图及关键代码介绍
项目流程图
屏幕检测到输入,然后根据两个点的位置连线,并把其映射到与书写区域同等大小二维数组上作为书写轨迹,当书写完成后,点击OK按钮,此时ESP32先运行预处理程序,把书写二维数组去掉其空白区域,保留其轮廓,并把其进行压缩和映射到28x28的一维数组上,供给ESP32上的深度学习模型进行推理,然后输出推理结果,最后在把推理结果输出到8x8LED点阵灯板上。同时当点击clean按钮时,清空画板的书写区域,同时把二维数组和一维数组都清空,并且会熄灭8X8LED点阵的所有灯。具体流程图如下:
单片机核心代码展示
预处理部分代码:
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "process.h"
#define TRACK_WIDTH 1
#define MARGIN 6
unsigned char arr112[SIZE_112][SIZE_112] = {0}; // 初始化二维数组为零
unsigned char pic_28_28[SIZE_28 * SIZE_28] = {0};
// 绘制两点之间的直线
void drawLine(unsigned char arr[SIZE_112][SIZE_112], int x1, int y1, int x2, int y2)
{
int dx = abs(x2 - x1);
int dy = abs(y2 - y1);
int sx = (x1 < x2) ? 1 : -1;
int sy = (y1 < y2) ? 1 : -1;
int err = dx - dy;
while (1)
{
arr[y1][x1] = 0xff;
if (x1 == x2 && y1 == y2)
{
break;
}
int e2 = 2 * err;
if (e2 > -dy)
{
err -= dy;
x1 += sx;
}
if (e2 < dx)
{
err += dx;
y1 += sy;
}
}
}
// 找出最大轮廓的边界
void findBoundary(unsigned char arr[SIZE_112][SIZE_112], int *minX, int *maxX, int *minY, int *maxY)
{
*minX = SIZE_112;
*maxX = -1;
*minY = SIZE_112;
*maxY = -1;
for (int y = 0; y < SIZE_112; y++)
{
for (int x = 0; x < SIZE_112; x++)
{
if (arr[y][x] == 0xff)
{
if (x < *minX)
*minX = x;
if (x > *maxX)
*maxX = x;
if (y < *minY)
*minY = y;
if (y > *maxY)
*maxY = y;
}
}
}
}
// 映射到 28x28 数组
void mapTo28x28(unsigned char arr112[SIZE_112][SIZE_112], unsigned char arr28[SIZE_28][SIZE_28],
int minX, int maxX, int minY, int maxY)
{
int width = maxX - minX + 1;
int height = maxY - minY + 1;
float scaleX = (float)(SIZE_28 - 2 * MARGIN) / width;
float scaleY = (float)(SIZE_28 - 2 * MARGIN) / height;
for (int y = minY; y <= maxY; y++)
{
for (int x = minX; x <= maxX; x++)
{
if (arr112[y][x] == 0xff)
{
int newX = (int)((x - minX) * scaleX) + MARGIN;
int newY = (int)((y - minY) * scaleY) + MARGIN;
// 绘制宽度为 2 的轨迹
for (int dy = 0; dy < TRACK_WIDTH; dy++)
{
for (int dx = 0; dx < TRACK_WIDTH; dx++)
{
if (newY + dy < SIZE_28 && newX + dx < SIZE_28)
{
arr28[newY + dy][newX + dx] = 0xff;
}
}
}
}
}
}
}
// 将二维 28x28 数组转换为一维数组
void convertTo1D(unsigned char arr28[SIZE_28][SIZE_28], unsigned char arr1D[SIZE_28 * SIZE_28])
{
int index = 0;
for (int i = 0; i < SIZE_28; i++)
{
for (int j = 0; j < SIZE_28; j++)
{
arr1D[index++] = arr28[i][j];
}
}
}
void process()
{
int minX, maxX, minY, maxY;
findBoundary(arr112, &minX, &maxX, &minY, &maxY);
unsigned char arr28[SIZE_28][SIZE_28] = {0};
mapTo28x28(arr112, arr28, minX, maxX, minY, maxY);
convertTo1D(arr28, pic_28_28);
}
推理输出部分代码:
void event_handler_2(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED)
{
int num = -1;
printf("按键2被按下\n");
process();
flag = 1;
int res = -1;
res = tm_preprocess(&mdl, TMPP_UINT2INT, &in_uint8, &in);
// TM_DBGT_START();
res = tm_run(&mdl, &in, outs);
// TM_DBGT("tm_run");
if (res == TM_OK)
{
num = parse_output(outs);
display_num = num;
}
else
TM_PRINTF("tm run error: %d\n", res);
tm_unload(&mdl);
print_image(pic_28_28, 28, 28);
memset(pic_28_28, 0, sizeof(pic_28_28));
memset(arr112, 0, sizeof(arr112));
char str[] = "Number is [ x ]";
sprintf(str, "Number is [ %d ]", num);
/* 设置标签文本 */
lv_label_set_text(label1, str);
}
}
绘画相关代码:
static lv_coord_t last_x, last_y = -32768;
if (code == LV_EVENT_PRESSING)
{
lv_indev_t * indev = lv_indev_get_act();
if(indev == NULL) return;
lv_point_t point;
lv_indev_get_point(indev, &point);
lv_color_t c0;
c0.full = 10;
lv_point_t points[2];
if ((last_x == -32768) || (last_y == -32768))
{
last_x = point.x-50;
last_y = point.y-50;
//printf("last_x:%d, last_y:%d\n", last_x, last_y);
}
else
{
points[0].x = last_x;
points[0].y = last_y;
points[1].x = point.x-50; //这里为了对其画板的坐标
points[1].y = point.y-50;
last_x = point.x-50;
last_y = point.y-50;
//printf("points[0].x:%d, points[0].y:%d,\n points[1].x:%d, points[1].y:%d\n", points[0].x, points[0].y, points[1].x, points[1].y);
drawLine(arr112, points[0].x, points[0].y, points[1].x, points[1].y); //绘画轨迹到二维数组上
lv_canvas_draw_line(obj, points, 2, &sketchpad->line_rect_dsc);
}
}
/*Loosen the brush*/
else if(code == LV_EVENT_RELEASED)
{
last_x = -32768;
last_y = -32768;
}
附加功能:手写计算器功能
本次设计还融入了手写计算器的设计,可以较好的完成个位数的加、减、乘运算。
设计思路为添加 ‘+‘ ’-‘ 'x'号到训练集中,从而可以实现识别操作运算符的功能,首先输入第一个数字,然后输入运算符,再然后输入第二个数字,同时输出结果。
具体实现代码如下
// 定义全局变量,用于存储各个标签对象
static lv_obj_t *label_num1;
static lv_obj_t *label_op;
static lv_obj_t *label_num2;
static lv_obj_t *label_equal;
static lv_obj_t *label_result;
// 当前需要输入的部分,使用枚举类型来清晰表示不同状态
typedef enum
{
INPUT_NUM1,
INPUT_OP,
INPUT_NUM2,
NO_INPUT
} InputState;
// 初始状态为输入第一个数字
InputState current_input = INPUT_NUM1;
// 闪烁定时器回调函数,控制下划线的闪烁
void blink_timer_cb(lv_timer_t *timer)
{
static bool blink_on = false;
blink_on = !blink_on;
switch (current_input)
{
case INPUT_NUM1:
if (blink_on)
{
lv_label_set_text(label_num1, "___");
}
else
{
lv_label_set_text(label_num1, "0~9");
}
break;
case INPUT_OP:
if (blink_on)
{
lv_label_set_text(label_op, "___");
}
else
{
lv_label_set_text(label_op, "+-x");
}
break;
case INPUT_NUM2:
if (blink_on)
{
lv_label_set_text(label_num2, "___");
}
else
{
lv_label_set_text(label_num2, "0~9");
}
break;
case NO_INPUT:
break;
}
}
// 处理输入的函数,根据当前输入状态处理不同的输入
void handle_input(char input)
{
switch (current_input)
{
case INPUT_NUM1:
if (input >= '0' && input <= '9')
{
char num_str[2];
num_str[0] = input;
num_str[1] = '\0';
lv_label_set_text(label_num1, num_str);
current_input = INPUT_OP;
}
break;
case INPUT_OP:
if (input == '+' || input == '-' || input == '*' || input == '/')
{
char op_str[2];
op_str[0] = input;
op_str[1] = '\0';
lv_label_set_text(label_op, op_str);
current_input = INPUT_NUM2;
}
break;
case INPUT_NUM2:
if (input >= '0' && input <= '9')
{
char num_str[2];
num_str[0] = input;
num_str[1] = '\0';
lv_label_set_text(label_num2, num_str);
current_input = NO_INPUT;
// current_input = INPUT_NUM1;
// 计算结果
int num1 = lv_label_get_text(label_num1)[0] - '0';
int num2 = lv_label_get_text(label_num2)[0] - '0';
char op = lv_label_get_text(label_op)[0];
int result;
switch (op)
{
case '+':
result = num1 + num2;
break;
case '-':
result = num1 - num2;
break;
case '*':
result = num1 * num2;
break;
case '/':
if (num2 != 0)
{
result = num1 / num2;
}
else
{
result = 0;
}
break;
}
char result_str[10];
snprintf(result_str, sizeof(result_str), "%d", result);
lv_label_set_text(label_result, result_str);
}
break;
case NO_INPUT:
break;
}
}
// 创建界面元素的函数,将各个标签添加到屏幕并设置位置
void create_calculator_ui(void)
{
// UI 部分略
// 创建闪烁定时器,设置每 500 毫秒触发一次
lv_timer_create(blink_timer_cb, 500, NULL);
}
深度学习训练相关代码
本次项目使用LeNet来训练模型,LeNet(LeNet-5)由两个部分组成:
- 卷积编码器:由两个卷积层组成;
- 全连接层密集块:由三个全连接层组成。
其架构如下图所示(图片来源网络):
每个卷积块中的基本单元是一个卷积层、一个sigmoid激活函数和平均汇聚层。每个卷积层使用卷积核和一个sigmoid激活函数。这些层将输入映射到多个二维特征输出,通常同时增加通道的数量。第一卷积层有6个输出通道,而第二个卷积层有16个输出通道。每个池操作(步幅2)通过空间下采样将维数减少4倍。卷积的输出形状由批量大小、通道数、高度、宽度决定。
为了将卷积块的输出传递给稠密块,我们必须在小批量中展平每个样本。换言之,我们将这个四维输入转换成全连接层所期望的二维输入。这里的二维表示的第一个维度索引小批量中的样本,第二个维度给出每个样本的平面向量表示。LeNet的稠密块有三个全连接层,分别有120、84和10个输出。因为我们在执行分类任务,所以输出层的10维对应于最后输出结果的数量。(来源于:动手学深度学习)
def init_model():
model = Sequential()
model.add(Conv2D(4, (5, 5), padding='same', strides=(2, 2), input_shape=(28, 28, 1)))
model.add(BatchNormalization())
model.add(Activation('relu'))
model.add(Dropout(0.3))
model.add(Conv2D(4, (5, 5), padding='same', strides=(3, 3)))
model.add(BatchNormalization())
model.add(Activation('relu'))
model.add(Flatten())
model.add(Dense(NUM))
model.add(Dropout(0.3))
model.add(Activation('softmax'))
return model
model = init_model()
model.summary()
# 编译模型
model.compile(optimizer='adam', loss="categorical_crossentropy", metrics=["categorical_accuracy"])
损失率与正确率:
功能展示图
手写识别功能展示:
手写计算器功能展示:
项目中遇到的难题和解决方法
本次项目中的手写识别算法遇到了很大的问题,之前编写单片机程序都是写死的程序逻辑,然而这一次写死的逻辑很难应用到本次项目中,因此上网进行了很多搜索,最后了解到深度学习算法,然后开始学习。期间的训练以及部署都遇到了困难,首先训练上,目前有多种框架,不过最后还是选择了tensorflow框架,由于该框架较好的支持了轻量设备,同时代码部署也遇到了一些问题,查看参考文章时,原作者将其应用到的并非ESP32,而是其他平台,移植时,一直会出现板子不断复位的情况,经过与作者进行交流后得知,其使用的平台为大端的架构,而ESP32属于是小端架构,因此需要改动参数,在其指导下顺利移植到本项目中。
对本次活动的心得体会
本次项目使我完整的体验了一次将深度学习与单片机结合的快乐,之前只觉得深度学习特别的高大上,只能在性能高的电脑上运行。通过本次学习了解了深度学习的整个流程,并且学习到了部署到轻量设备的一些知识,如量化、裁剪等,让我对嵌入式AI有了全新的认识。
参考链接
MNIST·扩展数据集·handwriting recognition calculator·multi-target detection·explainability·extended dataset
GitHub - sipeed/TinyMaix: TinyMaix is a tiny inference library for microcontrollers (TinyML).
MNIST Digit Playground
6.6. 卷积神经网络(LeNet) — 动手学深度学习 2.0.0 documentation
lv_lib_100ask是基于lvgl库的各种开箱即用的方案参考或对lvgl库各种组件的增强接口
GitHub - liux-pro/Ai-Arithmetic