简介:本资源是面向嵌入式初学者与STM32进阶开发者的手写数字识别实战项目,聚焦于在资源受限的STM32F407系列MCU上部署轻量级图像识别功能,适用于智能终端、人机交互界面及IoT边缘识别等场景。压缩包共635个文件,含279个C源码(实现ADC采样、触摸坐标处理、特征提取与KNN分类核心逻辑)、275个头文件(封装驱动接口与算法参数)、44张PNG图像(含训练样本示意图与界面效果参考),以及lib库文件、Keil工程文件(uvprojx/uvoptx)和可执行hex镜像,整体大小为5.61MB。已有183人学习下载。项目提供完整可编译运行的HAL库工程,涵盖从触摸数据采集、笔迹归一化预处理、Zernike矩特征提取到嵌入式端KNN分类器部署的全流程代码,所有驱动已适配STM32F40X全系列,无需额外移植;目录结构按硬件抽象层、算法模块、应用逻辑分层组织,便于理解嵌入式AI落地的关键约束与优化思路。 手写数字识别这个题目,在PC端早就是烂大街的东西了,PyTorch里随便搭个两层全连接网络,MNIST测试集上拿个98%以上的准确率轻轻松松。但放到STM32F407这颗Cortex-M4单片机上,事情就没那么顺理成章了——没有操作系统,内存只有192KB,Flash也就512KB到1MB,你要在这么点资源里塞进一个神经网络,还得保证前向推理速度能用、识别结果靠谱,中间涉及的模型选型、权重导出、C代码移植和预处理细节,每一步都有坑。
我最近完整走了一遍这个流程,从PyTorch离线训练、模型裁剪、权重导出,到STM32F407上的工程搭建、前向推理实现,最后在正点原子探索者开发板上通过触摸屏手写输入把数字识别跑通了,单次推理耗时在毫秒级,交互体验基本跟手。这篇文章就把整个链路掰开揉碎讲一遍,包括我当时踩过的坑和最后采用的优化方案。无论你是正在做毕业设计的学生,还是想入门嵌入式AI的工程师,这篇内容都可以直接作为参考,至少能帮你少走两周弯路。
1. 项目整体设计与思路拆解
1.1 为什么选STM32F407而不是性能更强的平台
很多人看到“单片机跑AI”第一反应是“为什么不用树莓派或手机”,第二反应是“为什么不用带NPU的芯片”。这两个问题我在做项目前也反复权衡过,最后仍然选了STM32F407,核心原因是它刚好卡在“资源够用”和“上手门槛低”的平衡点上。
先说性能。STM32F407主频168MHz,Cortex-M4F内核带单精度硬件FPU,192KB SRAM,512KB到1MB Flash。这个配置跑一个中等规模的全连接网络完全没问题,784-64-10这种结构,单次前向推理的乘加运算量大约5万次,F407实测只需要几个毫秒。对比一下F103那种72MHz无FPU的M3内核,同样网络能慢出好几倍,交互起来就有明显卡顿感。
再说外设。F407的FSMC接口可以很方便地驱动TFT-LCD,DCMI接口能接摄像头,SPI、UART、USB也齐全,这意味着手写数字的“输入方式”可以灵活选择——触摸屏、摄像头、串口上位机都可以。而F103虽然也能做,但FSMC带宽、主频和外设丰富度都差一档。
最后是生态。正点原子、野火这些开发板资料极其丰富,STM32CubeMX生成工程很成熟,HAL库和标准库都能用,遇到问题几乎都能搜到答案。对一个要快速出结果的项目来说,资料多本身就是最大的优势。
从应用场景看,这个项目的价值也不只是“跑个demo”。脱机手写数字识别在智能仪表、电子签名板、工业字符识别这些场景里都有实际需求,单片机能做到低功耗、低成本、离线运行,刚好补上树莓派和手机方案覆盖不了的盲区。
1.2 模型选型:MLP还是CNN
确定平台之后,下一个关键决策就是模型结构。手写数字识别最经典的模型有两类:全连接网络(MLP)和卷积网络(CNN)。我最终主线用的MLP,但CNN也做了验证,这里把取舍讲清楚。
MLP的方案是:输入784个像素(28x28灰度图),经过一个64节点的隐藏层,ReLU激活,再经过10节点输出层,Softmax分类。这个结构参数量大概5万个float,约200KB,Flash放得下;计算量5万次乘加,对F407来说压力不大。最关键的是,MLP的每一层就是一次矩阵乘法加一个激活函数,用C语言实现极其直观,调试也方便,非常适合做MCU端移植的第一版。
CNN这边,LeNet-5是手写数字识别的经典结构,参数量约6万,比MLP多不了太多,但计算量完全不是一个量级。卷积层逐窗口滑动的计算方式,在168MHz的M4上跑一次完整推理要几秒到几十秒,取决于图像尺寸和层数,交互体验非常差。除非做极致的层融合和int8量化,否则在F407上跑CNN的性价比并不高。
我的建议是:毕设或第一个版本,老老实实用MLP把全链路跑通,之后再扩展CNN。MLP能让你把注意力集中在“单片机端如何部署”这个核心问题上,而不是被卷积层的移植细节拖住。
1.3 两种部署路线:手工移植还是STM32Cube.AI
模型有了,怎么把它弄到单片机上去,有两条路线。
路线一是手工移植:在PC端训练好模型后,提取权重和偏置,生成C语言数组,然后在STM32工程里手写前向推理代码。这条路线的优势是透明可控,每一层在干什么你都清清楚楚,答辩时能讲明白原理,出问题时也能一步步排查。劣势是改模型结构后要重新导出权重,步骤相对繁琐。
路线二是用官方工具STM32Cube.AI(也就是X-CUBE-AI),把训练好的Keras或TFLite模型直接转换成C代码。优势是省事,工具会自动做层融合和优化;劣势是版本兼容问题多,生成的代码可读性差,出问题不好定位,而且对模型层的支持也有限制,比如某些自定义层直接不支持。
两条路线我都试过,最终项目采用的是路线一。原因很简单:对于MLP这种简单结构,手写前向推理代码量并不大,而且能精确控制每一步的实现细节——比如矩阵乘法用CMSIS-DSP库加速,ReLU和Softmax按自己的方式优化。这些控制力是Cube.AI给不了的。当然,如果你的模型是复杂CNN且不介意黑盒,路线二也值得尝试。
2. 数据准备与离线训练
2.1 MNIST数据集与预处理要点
训练数据我用的是MNIST,60000张训练图、10000张测试图,28x28灰度,数字0-9,这是手写数字识别领域的标准数据集。PyTorch里用torchvision加载非常方便,但有几个预处理细节需要注意,直接影响后面部署到单片机上的效果。
首先是归一化。MNIST原始像素值是0到255的整数,训练时通常归一化到0到1或-1到1。这个归一化参数要记下来,因为部署到单片机上时,输入数据也要做同样的归一化操作,否则模型输出分布会不对。我在C代码里就是简单地把输入像素值除以255.0f,保证和训练时一致。
其次是数据增强。刚开始我做第一版训练时没加任何增强,MNIST测试集准确率97.5%,看着还行,但移植到触摸屏后识别率掉得惨不忍睹。原因很简单:MNIST里是规规矩矩居中缩放的印刷体手写数字,而触摸屏上写出来的数字歪歪扭扭、有大有小、还有断笔和噪声。后来我加了随机平移、随机旋转(±10度)、随机缩放这几类增强,测试集准确率提升到98.6%,关键是触摸屏场景下明显更稳了。
如果想让模型更贴合触摸屏输入,还有一个更狠的做法:自己在开发板上采集一批真实触摸笔迹,扩充训练集。这个属于进阶玩法,后面讲预处理时再展开。
2.2 网络结构与训练配置
我最终采用的网络结构是:
- 输入层:784个节点,对应28x28灰度图的像素值展开
- 隐藏层:64个节点,ReLU激活
- 输出层:10个节点,对应数字0-9
训练配置如下:损失函数用CrossEntropyLoss,优化器用Adam,初始学习率0.001,batch size 128,训练20个epoch。PyTorch代码很短,核心训练循环大概这样:
import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1), scale=(0.9, 1.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST('./data', train=False, transform=transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])) train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=128, shuffle=True) test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=256) class MLP(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 64) self.fc2 = nn.Linear(64, 10) def forward(self, x): x = x.view(-1, 784) x = torch.relu(self.fc1(x)) x = self.fc2(x) return x model = MLP() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) for epoch in range(20): for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() torch.save(model.state_dict(), 'mnist_mlp.pth')这个配置在我本机CPU上训练一个epoch也就十几秒,20个epoch几分钟就完成了,不需要GPU。训练完成后在测试集上评估一下,准确率应该在98%以上。如果用的是无增强版本,97%左右也正常。
这里有一个经验之谈:隐藏层节点数不要贪多。有人觉得128、256个节点准确率更高就往上加,但MCU端Flash和算力有限,64节点在准确率和资源占用之间是比较平衡的选择。实测64节点和128节点在MNIST上的准确率差距不到0.3%,但对F407来说,Flash占用和推理时间却差了一倍。
2.3 权重导出为C数组的完整脚本
训练完模型,最关键的一步是把权重导出来。PyTorch里Linear层的权重存在state_dict里,key分别是fc1.weight、fc1.bias、fc2.weight、fc2.bias。有一点要特别小心:PyTorch中Linear层权重的shape是(out_features, in_features),也就是W1的shape是(64, 784),W2的shape是(10, 64)。做前向推理时,如果按标准做法y = Wx + b来算,直接按这个行优先的顺序导出就能用。
下面是我用的导出脚本,把权重和偏置写成C语言的头文件数组:
import numpy as np import torch model = MLP() model.load_state_dict(torch.load('mnist_mlp.pth', map_location='cpu')) model.eval() params = model.state_dict() def dump_array(name, tensor): data = tensor.numpy().flatten() print(f'const float {name}[{data.size}] = {{') for i in range(0, data.size, 8): row = ', '.join(f'{v:.6f}f' for v in data[i:i+8]) print(f' {row},') print('};') dump_array('W1', params['fc1.weight']) dump_array('b1', params['fc1.bias']) dump_array('W2', params['fc2.weight']) dump_array('b2', params['fc2.bias'])注意我用了.6f格式化,保留6位小数,加上f后缀表示float常量。运行这个脚本,把输出内容保存成mnist_weights.h,后面在Keil工程里直接#include进来就能用。
还有一个强烈建议:导出时顺便做一次“PC端前向推理验证”。具体做法是,把导出的数组在Python里重新做一次矩阵乘法,对比PyTorch模型的输出,确保导出没有错位。这个步骤看着多余,但能省掉后面在单片机上查半天逻辑错误的时间。
3. STM32F407工程搭建与前向推理实现
3.1 CubeMX工程配置与CMSIS-DSP库接入
STM32端我用的是STM32CubeMX生成工程框架,具体芯片型号选STM32F407VET6(如果你的是F407ZGT6也行,配置基本一致)。需要使能的外设有:RCC时钟树配置为168MHz主频、FSMC接口驱动TFT-LCD、一个SPI接口接触摸屏、一个USART用于打印调试信息,另外配几个GPIO作为按键等功能。
时钟树配置是整个工程的基础,F407最高支持168MHz,用8MHz外部晶振,PLL倍频到168MHz,APB1总线时钟42MHz,APB2总线时钟84MHz。这些时钟配置对了,后面所有外设才能正常工作。
CMSIS-DSP库的加入方式有两种。一是通过CubeMX的Software Packs组件管理器,勾选CMSIS-DSP,让工程自动包含库文件;二是手动下载CMSIS-DSP源码包,把arm_math.h和对应的库文件(arm_cortexM4lf_math.lib)拷到工程目录。我用的是第二种,因为更可控,版本不会跟CubeMX自动生成的工程冲突。
直接在Keil工程里添加CMSIS-DSP要格外注意硬件浮点设置。Keil的Options for Target -> Target -> Floating Point Hardware必须选Single Precision。如果选错,FPU不会被正确启用,CMSIS-DSP的矩阵乘法虽然能跑,但性能会大打折扣,因为你可能在用软浮点模拟。检查方法很简单:编译后看map文件里有没有__hardfp相关的符号,或者直接看反汇编里有没有vadd.f32这类浮点指令。
3.2 前向推理的C代码实现细节
前向推理的逻辑就是上篇文章里MLP结构的直接翻译。输入是一个784长度的float数组,代表28x28灰度图(0到1区间),输出是10个float值,分别是数字0-9的分类得分,取最大值对应的索引就是识别结果。
我最初手写的矩阵乘法是三层嵌套循环,代码很直观,但F407实测一次推理大约花12毫秒。换成CMSIS-DSP库的arm_mat_mult_f32之后,推理时间直接降到了4毫秒左右,提速明显。所以强烈建议用DSP库,不要自己写矩阵乘法。
核心的predict函数长这样:
#include "arm_math.h" #include "mnist_weights.h" #define INPUT_SIZE 784 #define HIDDEN_SIZE 64 #define OUTPUT_SIZE 10 static float input_data[INPUT_SIZE]; static float hidden[HIDDEN_SIZE]; static float output[OUTPUT_SIZE]; void model_predict(float *input, uint8_t *result, float *confidence) { arm_matrix_instance_f32 W1 = {HIDDEN_SIZE, INPUT_SIZE, (float32_t *)W1_data}; arm_matrix_instance_f32 x_in = {INPUT_SIZE, 1, input}; arm_matrix_instance_f32 hidden_mat = {HIDDEN_SIZE, 1, hidden}; arm_mat_mult_f32(&W1, &x_in, &hidden_mat); for (int i = 0; i < HIDDEN_SIZE; i++) { hidden[i] += b1_data[i]; if (hidden[i] < 0.0f) hidden[i] = 0.0f; // ReLU } arm_matrix_instance_f32 W2 = {OUTPUT_SIZE, HIDDEN_SIZE, (float32_t *)W2_data}; arm_matrix_instance_f32 hidden_in = {HIDDEN_SIZE, 1, hidden}; arm_matrix_instance_f32 output_mat = {OUTPUT_SIZE, 1, output}; arm_mat_mult_f32(&W2, &hidden_in, &output_mat); for (int i = 0; i < OUTPUT_SIZE; i++) { output[i] += b2_data[i]; } softmax(output, OUTPUT_SIZE); *result = 0; float max_val = output[0]; for (int i = 1; i < OUTPUT_SIZE; i++) { if (output[i] > max_val) { max_val = output[i]; *result = i; } } *confidence = max_val; }有几个细节值得强调。
第一,权重数组W1_data、b1_data、W2_data、b2_data来自mnist_weights.h头文件,在编译时存入Flash。用const float修饰,确保不要把这个200多KB的数据放到SRAM里,否则192KB的SRAM直接爆掉。
第二,arm_mat_mult_f32的输入矩阵和输出矩阵必须预先分配好,它不会帮你动态分配内存。而且矩阵结构体里的指针需要指向内存对齐的数组,一般4字节对齐就够了,Keil默认的全局变量对齐没问题,但如果用动态分配就要小心。
第三,softmax函数我做了数值稳定性处理。标准的Softmax是先对指数求和再相除,但如果输入值稍大,exp会溢出变成inf。简单的方法是对所有输出减去最大值再做exp:
void softmax(float *x, int len) { float max_val = x[0]; for (int i = 1; i < len; i++) { if (x[i] > max_val) max_val = x[i]; } float sum = 0.0f; for (int i = 0; i < len; i++) { x[i] = expf(x[i] - max_val); sum += x[i]; } for (int i = 0; i < len; i++) { x[i] /= sum; } }其实对分类结果来说,argmax不依赖分母,Softmax最后一步归一化对判断类别没有影响,但保留它能拿到置信度,方便后面显示“这个数字的把握有多大”,调试时很有用。
3.3 触摸屏输入与图像预处理
模型推理跑通之后,真正的难点来了:怎么让触摸屏上手写的数字变成模型能用的28x28灰度图。这一步处理得不好,模型准确率再高也白搭。
我用的是LCD+电阻触摸屏方案,LCD分辨率480x320。我在屏幕上画了一个240x240的白色书写区域,用户在这个区域内用手指或笔书写,系统通过触摸屏控制器采集笔画坐标点,同时在LCD上实时画出来。
书写结束后,把笔画坐标转换成28x28的灰度图。具体做法是:把240x240的书写区域划分成28x28个网格,每个网格大约8.5x8.5像素。遍历笔画坐标点,根据坐标落在哪个网格,给对应网格的灰度值累加1。如果某格被笔迹覆盖了,灰度值高,反之则低。最后把所有网格值映射到0-255范围。
这个过程中最关键的坑是居中预处理。MNIST的训练样本都是把数字缩放并居中到28x28的中心区域,触摸屏直接按坐标划分的图完全不是这个分布,直接喂给模型,识别率会非常低。我一开始没做居中,误以为“模型应该能识别任意位置的数字”,结果数字稍微偏一点就识别错。
正确的做法是:计算笔迹的包围盒(所有笔迹点的最小x、最大x、最小y、最大y),然后把包围盒等比缩放到20x20大小,最后把这个20x20的图平移到28x28画布的中心位置。这正好是MNIST数据集的官方预处理方式。我加上这一步之后,触摸屏识别率提升非常明显。
实际代码中,这个缩放和平移要在灰度网格上做一次双线性插值,否则缩放的锯齿会引入噪声。如果嫌插值麻烦,简单地取最近邻也行,但建议还是做双线性,代码量不大,效果更稳。
另外,触摸屏坐标方向是个容易踩的坑。很多开发板的触摸屏X方向和LCD显示的X方向是反的,笔画会镜像显示。如果用户写的明明是“7”,显示出来却是个镜像的“L”,模型自然会识别错。调试时先画一个十字交叉线,检查触摸画出的线和LCD坐标是否一致,再往下做。
4. 性能优化与内存管理
4.1 推理耗时实测与优化方向
先给一组实测数据:我最初手写的三层循环矩阵乘法,主频168MHz,不开编译器优化时单次推理约20毫秒。开了Keil -O2优化后降到12毫秒左右。换成CMSIS-DSP的arm_mat_mult_f32后,直接降到4毫秒左右。如果再把优化等级开到-O3,可以压到3毫秒上下。
这个速度对触摸屏手写场景完全够用。人的书写动作一般是几百毫秒,松笔之后立刻开始推理,3毫秒的耗时用户根本感知不到,LCD上刚画完笔画,识别结果马上就能弹出来。
如果你对耗时敏感,还有个简单的测量手段:用DWT数据观察点计数器。F407内部有DWT模块,可以用来做精确的周期计数,精度比SysTick高得多。初始化代码:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;然后在推理前清零,推理后读DWT->CYCCNT,除以168MHz就是秒数。这个方法不会打断程序执行,非常适合做性能分析。
4.2 内存占用分析与量化取舍
这个MLP模型的内存账很好算。权重W1是64x784=50176个float,约200KB;b1是64个float,约256字节;W2是10x64=640个float,约2.5KB;b2是10个float,40字节。全部加起来约203KB,放到Flash里完全没问题(F407VET6是512KB Flash)。
运行时SRAM方面,输入数组784个float约3KB,隐藏层64个float加输出层10个float,加上矩阵乘法临时变量,总共不超过10KB。对192KB的SRAM来说非常宽裕。
但如果你想把模型换成更大的结构,或者用CNN,内存就要精打细算了。一个常用的优化手段是int8量化:把float权重量化到int8,Flash占用直接降到原来的四分之一,200KB变50KB。代价是前向推理代码要额外处理scale和zero_point,而且F407没有原生的int8矩阵加速指令,计算速度不一定比float快。所以我的建议是:在F407上做MLP推理,float就够了,不需要量化。量化更适合资源更紧张的场景,比如F103或更高层的CNN。
4.3 FPU与CMSIS-DSP的使用细节
既然F407带FPU,就要把它的性能吃满。有几个细节直接影响浮点性能。
第一,确认FPU被正确使能。Keil工程里,Target选项页的Floating Point Hardware必须选Single Precision,并且编译器的Define里最好加上__FPU_USED=1和__FPU_PRESENT=1。启动文件里也有一段FPU使能代码(SCB->CPACR |= ((3UL << 10*2) | (3UL << 11*2))),标准启动文件默认包含,但如果是自己精简过的启动文件,容易漏掉,后果是浮点指令进HardFault。
第二,CMSIS-DSP库的矩阵乘法函数对内存对齐有要求。arm_matrix_instance_f32结构体里的pData指针建议4字节对齐,如果是从外部SRAM分配的内存,尤其要注意。F407的外部SRAM基址通常是0x68000000或0x60000000,对齐没问题,但如果自己做了偏移就要小心。
第三,不要在中断服务函数里做推理。触摸屏的SPI中断、定时器中断会频繁打断推理过程,导致推理时间不稳定,甚至因为超时丢数据。正确的做法是:触摸屏中断只负责记录坐标和置一个标志位,主循环检测到“书写结束”标志后再执行推理。这样推理是一个原子操作,耗时稳定,也不会漏掉触摸事件。
5. 常见问题与排查技巧实录
5.1 编译链接失败或Flash不足
这是我在项目初期遇到最多的编译问题。典型报错是Keil的L6220E: Region RX overflowed by xxx bytes,意思是代码和常量数据超出了Flash容量。
排查思路其实很清晰。首先确认权重数组是不是用const声明了,如果忘了加const,编译器会把这个200KB的数组放到可读写的SRAM区,F407的192KB SRAM根本扛不住,即使没报Flash溢出也会报内存不足。其次把编译优化等级从-O0调到-O2,代码体积能减少不少。再者检查有没有不小心把printf的重定向、FPU库、DSP库整个连进来,这些都会占Flash。最后,如果实在放不下,最粗暴的办法是缩小隐藏层,从64降到48或32,准确率损失很小,但Flash占用直接砍掉三分之一以上。
5.2 触摸屏识别率低不一定是模型的问题
模型在MNIST测试集98%以上,触摸屏上一测只有60%,这个问题我调试了整整两天,最后发现模型本身没问题,是预处理环节的锅。
最典型的三类问题:一是没有居中缩放,数字在28x28画布里的位置和MMNIST训练样本差异太大;二是二值化阈值不合适,触摸屏笔画颜色浅、压力轻,灰度值没有正确映射到0-255;三是触摸屏坐标镜像或旋转,导致数字形状都变了。
排查方法是分步做,别一上来就猜模型。第一步,把每个手写数字的28x28网格数据通过串口打印出来,在PC端还原成ASCII图,肉眼看这个图是不是一个清晰、居中、结构正常的数字。如果ASCII图看起来都别扭,说明预处理有问题,先调预处理。如果ASCII图正常但识别结果还是错,再接一个调试手段:把模型的10个输出概率通过串口打出来,看模型是“错得犹豫”还是“错得自信”。错得犹豫说明特征不明确,错得自信说明模型学到的东西跟输入的分布不一致,通常还是预处理导致的分布偏移。
5.3 推理偶尔卡顿或死机
这个问题的别名叫“HardFault”。我在调试时遇到过一次,现象是笔画一多就死机。排查后定位到两个原因:一是在触摸屏中断里分配了较大的局部数组,导致栈溢出;二是DSP矩阵乘法访问了未对齐或者越界的指针。
解决方案是:把所有中间计算的大数组(比如输入数组、隐藏层、输出层)都定义为全局静态数组,不要放在函数内部。这样它们分配在.bss段,不占用栈空间。另外,在HardFault_Handler里加一个调试断点,挂上调试器后可以在中断里查看LR寄存器和栈回溯,定位到具体是哪个函数触发的异常,这个方法能救命的。
5.4 CMSIS-DSP库和HAL库的版本冲突
这个坑比较隐蔽。我在一个CubeMX生成的工程里手动加入了CMSIS-DSP库的源码,编译时出现一堆重复定义的报错,原因是CubeMX默认也会在工程里加入一部分CMSIS-Core的相关文件,两边的头文件版本不一致导致宏重复。
解决办法很简单:要么只用CubeMX的Software Packs方式添加CMSIS-DSP,不要手动再加;要么手动添加时,把CubeMX自动生成的CMSIS-DSP相关文件从工程里删掉,只保留一套。另外,CMSIS-DSP的版本要跟CMSIS-Core版本匹配,如果工程用的CMSIS是5.x,DSP库也尽量用5.x系列。
6. 项目扩展方向与我的实际体会
MLP全链路跑通之后,这个项目还有很多可以玩的空间。我自己接着做了两个扩展,都验证可行,这里一并分享。
第一个扩展是摄像头输入。F407的DCMI接口可以接OV7670或OV2640摄像头模块,拍摄纸上手写的数字,然后用像素值判断数字区域,裁剪、缩放、灰度化后送入同一个模型。这个方案的预处理比触摸屏复杂得多,因为要处理背景、光照、数字定位等问题,但做出来之后效果很惊艳,更像是“真实产品”的感觉。
第二个扩展是增加识别类别。手写数字识别只有10类,如果你把模型输出从10改成26+10,输入尺寸稍作调整,就能识别手写字母。训练数据可以自己采集,或者用EMNIST的字母数据集。STM32F407的资源跑一个字母识别MLP依然没问题,Flash占用和推理时间都在可接受范围内。
最后说一点个人经验。这个项目最核心的难点,在我看来其实不是单片机端的代码,而是“让预处理方式与训练数据分布保持一致”这件事。PC端训练你有一万种方法把数据洗干净,但到了嵌入式设备上,物理世界的噪声、触摸屏的飘移、光照变化都会让输入数据偏离训练分布。处理不好这一层,模型再优秀也无法落地。
踩过几次坑之后,我现在做任何嵌入式AI项目,都会先花时间把“采集输入到模型输入”这一步做成可视化——在PC端写个模拟器,把同样的预处理代码跑一遍,看看输出图像是否合理,再决定要不要上板子。这比在单片机上一遍遍烧录调试要高效得多。希望这篇内容能帮你少走一些弯路,早日跑通自己的手写数字识别项目。
本文还有配套的精品资源,点击获取