
简介本资源是面向嵌入式初学者与STM32进阶开发者的手写数字识别实战项目基于STM32F407微控制器实现端侧轻量级AI识别适用于智能人机交互、IoT终端、教学实验等场景。项目完整集成ADC数据采集、图像预处理、特征提取及KNN/SVM分类模型部署支持STM32F40X全系列芯片如F405/F407提供寄存器、标准外设库SPL和HAL三种驱动方案兼顾性能与可移植性。压缩包含635个文件以279个C源码和275个头文件构成核心算法与硬件驱动框架辅以44张PNG示意图、8个预编译库及多个Keil工程文件.uvprojx/.uvoptx总大小5.61MB结构清晰、模块解耦便于分层理解与二次开发。已有183人学习下载配套代码经实测可直接编译运行涵盖触摸坐标采集、滤波去噪、归一化缩放、Zernike矩特征计算及识别结果LCD显示全流程是掌握嵌入式AI落地的典型参考范例。 先说结论这个项目最卡人的地方不是网络训练而是怎么把训练好的网络塞进Cortex-M4这种资源有限的环境里还能老老实实跑起来。我这次用的是STM32F407168MHz主频、192KB RAM、1MB Flash对于“手写数字识别”这种规模的小网络来说绰绰有余整套代码也能直接迁到F405、F415这些同系列芯片上。文章会把模型训练、权重量化、STM32端算子和工程整合全部串起来讲适合有单片机基础、又想试试“在MCU上跑神经网络”的读者也适合准备毕业设计或电赛题目的人直接抄作业。手写数字识别这个任务看着小实际上是把神经网络前向推理完整走了一遍。从PC端训练一个模型到把权重裁成int8数组再到单片机上一层层手工实现卷积、池化、全连接整个过程走通了你对嵌入式AI的理解会从“不明觉厉”变成“就这么回事”。1. 项目立项为什么要在单片机上做手写数字识别1.1 这个需求到底解决了什么问题很多人一听到“STM32跑神经网络”就觉得很玄。其实手写数字识别是MNIST级别的任务输入是28x28的灰度图输出是0到9十个分类的概率。这类任务放在PC上几乎是“Hello World”但放到单片机上就逼迫你把很多被框架隐藏的细节手动挖出来。真实场景里野外作业的手持终端、离线答题卡识别设备、仓库分拣系统的本地校验模块都可能需要在不联网、不上云的情况下识别几个数字。如果用树莓派成本高、功耗大、启动慢如果用STM32这种MCU单片成本十几块上电即跑功耗也能压到很低。所谓“边缘计算”很多场景其实就是这么朴素数据不出本地、响应要快、成本要低。这个项目的技术栈正好覆盖了这几件事摄像头或传感器采集图像、单片机做图像预处理、本地完成神经网络推理、最后通过串口或者屏幕输出结果。整体是一个完整的最小嵌入式AI原型。1.2 硬件资源摸底F407到底够不够用我选STM32F407的原因很直接主频168MHz带单精度FPU1MB Flash192KB SRAM。这在Cortex-M4家族里属于“豪华配置”应付一个参数总量在10万量级的小网络完全没压力。来算一笔账。如果我用一个微型CNN网络结构是“1层卷积池化2层全连接”参数总量大概10万个。按float32存储每个参数4字节总共约400KB按int8存储只要100KB出头。STM32F407的Flash有1MBint8版权重只占十分之一即使代码和中间数据都算上也只占用Flash的一小部分。RAM方面推理时的中间特征图也很小。28x28x1的输入图像784字节经过8个3x3卷积核得到28x28x8的特征图6272字节。池化后变成14x14x81568字节。整个推理过程中所有中间buffer加起来不超过12KB。F407的192KB SRAM用起来非常宽裕。我之前见过有人用F103做类似项目也能跑但F103是72MHz、只有64KB RAM、20KB CCM模型稍微大一点就各种抠内存。F407起步会舒服很多这也是很多教学板和DIY项目选F407的原因。1.3 网络结构选型不要一上来就搬LeNet手写数字识别最常见的网络是LeNet-5这个网络是1998年Yann LeCun设计的结构是“卷积-池化-卷积-池化-全连接-全连接”参数大概6万个看起来不大。但如果直接搬到单片机上F407也能跑只是没必要因为LeNet-5的设计目标包含了一定规模的特征提取能力而MNIST数字本身的结构相对简单。我最终选了一个更小的“瘦身版CNN”输入28x28灰度图第一层8个3x3卷积核paddingsameReLU激活第二层2x2最大池化第三层全连接64个神经元ReLU激活第四层全连接10个神经元Softmax输出这个结构只有10万个参数计算量大概15万次乘加。在168MHz的F407上即使手写C代码不做特殊优化单次推理也就几毫秒完全满足实时性要求。如果换成纯全连接网络784-128-64-10参数也有10万个左右计算量相近。但CNN因为有卷积的局部连接特性对数字的平移和形变鲁棒性更好所以同等参数量的情况下精度会略高。这也是我坚持用CNN而不是MLP的原因。另外一个原因是卷积层在单片机上的实现是很好的“算子练习”。当你亲手把im2col、滑窗卷积、池化这些概念用C写出来再去调试你对神经网络底层的数据流理解会完全不一样。2. 模型训练与权重导出2.1 用Keras快速训练一个微型CNN训练部分是在PC上完成的。我用的是TensorFlow的Keras接口没有特意调参几行代码就能训练到98%以上的准确率。如果你的习惯是PyTorch也可以用PyTorch训练最终导出权重的方式是一样的核心就是把权重的数值提取出来。训练代码如下注意输入部分我做了“减128”的归一化处理这一步很重要是为了和单片机端的int8输入对齐import numpy as np import tensorflow as tf from tensorflow.keras import layers, models (x_train, y_train), (x_test, y_test) tf.keras.datasets.mnist.load_data() # 归一化到[-128, 127]范围和单片机端raw pixel - 128一致 x_train x_train.astype(np.float32) - 128.0 x_test x_test.astype(np.float32) - 128.0 x_train x_train.reshape(-1, 28, 28, 1) x_test x_test.reshape(-1, 28, 28, 1) model models.Sequential([ layers.Conv2D(8, (3, 3), activationrelu, paddingsame, input_shape(28, 28, 1)), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(64, activationrelu), layers.Dense(10, activationsoftmax) ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) model.fit(x_train, y_train, epochs5, batch_size128, validation_data(x_test, y_test))训练5个epoch测试集准确率一般在98.5%左右。这个精度对实际使用已经非常好了MNIST本身是个很“乖”的数据集数字居中、背景干净、笔画规整。这里有个细节我必须强调把输入减去128而不是除以255是为了部署时省一步浮点除法。单片机拿到摄像头传回来或者触摸屏上画的像素本来就是0到255的灰度值直接减去128就得到有符号int8数据省掉一次除法运算。训练和推理的输入分布保持一致模型精度不会出问题。2.2 权重量化float32到int8的完整思路模型训练好后权重是float32。直接把float32数组搬到单片机上当然也能跑F407带FPU浮点运算消耗不算大。但既然要做嵌入式AI量化是绕不开的话题而且int8权重体积只有float32的四分之一读取更快、缓存更友好。量化的基本公式并不复杂。假设一组浮点权重w实际取值范围是[-max_w, max_w]我们要把它映射到int8的[-128, 127]最简单的对称量化方式是scale max_w / 127.0 q round(w / scale)反量化就是w约等于q * scale。这就是训练后量化Post-Training Quantization的最简形式。但这里有一个新手特别容易踩的坑只量化权重是不够的每层的激活值scale必须同步处理。我举个例子假设第一层卷积的权重scale是0.05输入图像的scale是1.0那么卷积累加出来的int32结果乘以0.05才是真实浮点值再除以当前层输出scale才得到下一层的int8输入。如果忽略这个scale传递关系只把权重转成int8然后用int8直接乘加最后把int32结果强行截断成int8精度会掉得非常厉害甚至可能完全跑不出结果。网上很多所谓“单片机手写数字识别”的代码就是这么写的只能跑通作者自己预设的输入换个摄像头采的图就废了。实际生成C头文件时我会把每一层的权重和偏置都量化好同时把“组合scale因子”也一并导出来C代码里每个输出神经元只需要做一次浮点乘法来修正scale开销完全可以忽略。2.3 权重导出为C数组的Python脚本训练完成并量化后我用一个Python脚本把模型里的每一层权重抽出来量化为int8和int32偏置然后生成一个model_data.h文件。代码不复杂关键是把内存布局和C代码里的索引方式对齐。def quantize_array(arr): max_val np.abs(arr).max() 1e-6 scale max_val / 127.0 q np.round(arr / scale).astype(np.int8) return q, scale # 假设model已经训练好 for i, layer in enumerate(model.layers): if isinstance(layer, layers.Conv2D) or isinstance(layer, layers.Dense): w, b layer.get_weights() qw, w_scale quantize_array(w) qb np.round(b / w_scale).astype(np.int32) # 将qw转成C数组写入文件导出后C语言头文件里的结构大概是const int8_t conv1_weight[8][1][3][3] { ... }; const int32_t conv1_bias[8] { ... }; const float conv1_w_scale 0.0234f; const int8_t fc1_weight[1568][64] { ... }; const int32_t fc1_bias[64] { ... }; const float fc1_w_scale 0.0187f;这里需要特别说明第一个全连接层的输入尺寸是14x14x81568所以权重矩阵的维度是1568行乘64列。有的C代码里会把权重数组按行优先或列优先存放索引搞反了会导致输出完全错误。我的习惯是训练时Keras里Dense层的权重shape是(input_dim, output_dim)导出时按这个顺序展开C代码里访问时用[o * input_dim i]对应输出第o个神经元、输入第i个值。这一点必须保持严格一致。3. STM32F407端工程搭建与推理实现3.1 MDK工程结构与基础配置工程我用的是标准外设库SPL因为对这个项目来说简单直接不需要HAL那么厚的一层封装。如果你习惯HAL库也完全没问题核心推理代码和库无关。工程目录大致长这样Project/ ├── Core/ │ ├── main.c │ ├── stm32f4xx_it.c │ └── system_stm32f4xx.c ├── Model/ │ ├── model_data.h │ └── nn_infer.c / nn_infer.h ├── BSP/ │ ├── uart.c │ ├── camera.c │ └── lcd.c └── Libraries/时钟配置用外部8MHz晶振倍频到168MHz。这里提醒一句如果用了PLL倍频一定要确认SysTick中断优先级和延时函数正常否则后面调试串口输出会踩坑。主流程比较简单上电后初始化串口和可选的外设然后进入一个循环每次拿到一张28x28的灰度图调用推理函数把结果通过串口发出去或者显示在屏幕上。int main(void) { uint8_t image[784]; // 28x28灰度图 float probs[10]; SystemInit(); UART_Init(115200); // 摄像头或触摸屏初始化按需开启 while (1) { capture_image(image); // 采集或加载一幅手写数字图 nn_infer(image, probs); // 神经网络前向推理 for (int i 0; i 10; i) { printf(%d: %.3f\r\n, i, probs[i]); } delay(500); } }3.2 输入图像处理从摄像头原始数据到q7输入摄像头直接采集的图通常是RGB565格式首先要转成灰度再缩放到28x28。这一步的细节直接影响识别精度后面我会专门讲。假设你要处理的是触摸屏上画的笔画也需要把触摸点轨迹栅格化到28x28网格。转换后得到的是每个像素0到255的灰度值。推理代码入口处第一步就是把unsigned char的灰度值转成int8_tstatic int8_t input_q7[784]; void prepare_input(const uint8_t *raw) { for (int i 0; i 784; i) { input_q7[i] (int8_t)(raw[i] - 128); } }这里就是训练时用“减128归一化”的原因部署时一行代码完成转int8。如果你训练时用的是除以255这里就要乘一个127/255的浮点系数虽然也能做但多一道运算还会引入微小误差。3.3 卷积层、池化层和全连接层的C代码实现现在进入核心部分手写算子。先看卷积层。我的网络第一层是8个3x3卷积核输入是28x28x1paddingsame所以输出是28x28x8。void conv2d_3x3_q7(const int8_t *input, int in_h, int in_w, int in_c, const int8_t *weight, const int32_t *bias, int out_c, int K, int pad, float w_scale, float in_scale, float out_scale, int8_t *output) { int out_h in_h 2 * pad - K 1; int out_w in_w 2 * pad - K 1; float multiplier w_scale * in_scale / out_scale; for (int oc 0; oc out_c; oc) { for (int oh 0; oh out_h; oh) { for (int ow 0; ow out_w; ow) { int32_t acc bias[oc]; for (int ic 0; ic in_c; ic) { for (int kh 0; kh K; kh) { for (int kw 0; kw K; kw) { int ih oh kh - pad; int iw ow kw - pad; if (ih 0 ih in_h iw 0 iw in_w) { int w_idx ((oc * in_c ic) * K kh) * K kw; int i_idx (ic * in_h ih) * in_w iw; acc (int16_t)weight[w_idx] * (int16_t)input[i_idx]; } } } } float result (float)acc * multiplier; if (result 0) result 0; // ReLU int8_t q (int8_t)roundf(result); if (q -128) q -128; if (q 127) q 127; int o_idx (oc * out_h oh) * out_w ow; output[o_idx] q; } } } }这段代码本身不复杂但有几个地方必须注意。最内层循环里weight和input都是int8_t相乘之前必须转成int16_t否则两个int8相乘默认会先整型提升但可能因编译器标志不同产生符号扩展问题我踩过这个坑排错排了很久。累加器acc用了int32_t因为一次卷积要累加9个int8乘int8的乘积最大峰值为1281289147456远没到int32上限但如果是更大的网络比如输入通道数很多、卷积核很大int32也不一定够到时候要换成int64或者分段累加。还有一个容易被忽略的点对边界像素做padding处理时我用了if判断在编译优化开-O2的情况下这个开销并不大。如果你追求极致性能可以单独写边界循环主循环去掉判断这样能快一些但代码会变得冗长。这个项目里完全没必要。池化层更简单2x2窗口取maxvoid maxpool2x2_q7(const int8_t *input, int in_h, int in_w, int in_c, int8_t *output) { int out_h in_h / 2; int out_w in_w / 2; for (int c 0; c in_c; c) { for (int oh 0; oh out_h; oh) { for (int ow 0; ow out_w; ow) { int8_t max_val -128; for (int dh 0; dh 2; dh) { for (int dw 0; dw 2; dw) { int v input[(c * in_h oh * 2 dh) * in_w ow * 2 dw]; if (v max_val) max_val v; } } output[(c * out_h oh) * out_w ow] max_val; } } } }池化层没有权重也没有scale问题直接把上一层的int8输出切块取最大值。原理上讲最大池化保留了局部区域最活跃的特征响应对小幅平移有一定鲁棒性。全连接层是实现上最“重量级”的部分因为第一层全连接要把1568个池化后的特征值映射到64个神经元。这层的权重矩阵很大1568x64100352个int8参数是整个模型体积的主要来源。计算过程依然是乘积累加然后做一次scale修正和ReLUvoid fully_connected_q7(const int8_t *input, int in_dim, const int8_t *weight, const int32_t *bias, int out_dim, float w_scale, float in_scale, float out_scale, int8_t *output, int relu) { float multiplier w_scale * in_scale / out_scale; for (int o 0; o out_dim; o) { int32_t acc bias[o]; for (int i 0; i in_dim; i) { acc (int16_t)weight[o * in_dim i] * (int16_t)input[i]; } float result (float)acc * multiplier; if (relu result 0) result 0; int8_t q (int8_t)roundf(result); if (q -128) q -128; if (q 127) q 127; output[o] q; } }最后一个全连接层输出10个值不做ReLU而是交给Softmax。Softmax我用浮点实现因为F407有FPU计算exp的耗时可以接受而且用浮点更直观void softmax_q7(const int8_t *logits, float *probs, int n) { float max_val -1e30f; for (int i 0; i n; i) { if ((float)logits[i] max_val) max_val (float)logits[i]; } float sum 0.0f; for (int i 0; i n; i) { probs[i] expf((float)logits[i] - max_val); sum probs[i]; } for (int i 0; i n; i) { probs[i] / sum; } }减去最大值是为了防止exp溢出。虽然logits是int8最大值不会超过127直接exp也行但保留这个习惯对以后扩展更大的网络有好处。3.4 主推理流程组装把以上算子串联起来就是完整的推理函数typedef struct { const int8_t *conv1_w; const int32_t *conv1_b; float conv1_w_scale; float conv1_out_scale; // ... 其他层参数 } NNConfig; int nn_infer(const uint8_t *raw_image, float *probs) { static int8_t input_q7[784]; static int8_t conv1_out[28 * 28 * 8]; static int8_t pool1_out[14 * 14 * 8]; static int8_t fc1_out[64]; static int8_t fc2_out[10]; prepare_input(raw_image); conv2d_3x3_q7(input_q7, 28, 28, 1, conv1_weight, conv1_bias, 8, 3, 1, conv1_w_scale, input_scale, conv1_out_scale, conv1_out); maxpool2x2_q7(conv1_out, 28, 28, 8, pool1_out); fully_connected_q7(pool1_out, 14 * 14 * 8, fc1_weight, fc1_bias, 64, fc1_w_scale, conv1_out_scale, fc1_out_scale, fc1_out, 1); fully_connected_q7(fc1_out, 64, fc2_weight, fc2_bias, 10, fc2_w_scale, fc1_out_scale, fc2_out_scale, fc2_out, 0); softmax_q7(fc2_out, probs, 10); return 0; }这里我把中间buffer声明成static而不是局部变量是因为局部大数组会占用栈空间F407的栈默认只有1KB左右很容易栈溢出然后HardFault。static变量放在全局区或者BSS段RAM完全够用还省得每次调用重新初始化。4. 性能优化与资源规划4.1 先跑通再优化float版本和int8版本怎么选这个项目我之前做过一版float32的推理代码几乎一样只是权重数组用float存储卷积累加直接用float不需要scale转换。代码写起来更简单调试也更方便。F407有FPUfloat数组跑起来并不慢100KB权重直接从Flash读取168MHz下推理一次也就5到10毫秒。如果你的目的是快速验收、先把功能跑通我建议第一次做用float版本。等整个链路稳定了再切换成int8顺便体会一下量化带来的体积缩减和潜在的精度变化。两个版本的工程结构完全一致替换起来很快。int8版本的主要优势是模型体积小。float版本权重占400多KBint8版本只要100多KB。对于F407的1MB Flash这个区别暂时不影响能不能装下但如果后续把网络加大或者想移植到Flash更小的芯片上int8的优势就体现出来了。4.2 计算量拆解一次推理到底做了多少乘加我估算过这个微型CNN一次推理的总乘加次数网络层输出维度乘加次数Conv128x28x828288*9 56,448FC1641568*64 100,352FC21064*10 640合计-约157,440大约15.7万次乘加。168MHz主频下理想的单周期乘加指令比如带DSP扩展的SMMLA之类理论上只需要不到1毫秒。实际手写循环有边界判断、地址计算、循环控制等额外开销我实测大约3到5毫秒一次完整推理。这个速度对实时识别绰绰有余你手写数字的速度不可能比这个还快。如果后续网络变大计算量变成百万级、千万级再考虑引入CMSIS-NN或者DSP库优化。目前这个规模手写循环配合编译器的-O2优化已经足够了。4.3 Flash和RAM占用实测我把生成后的bin文件烧进F407用MAP文件看了下资源占用。int8版本整体Flash占用大约130KB其中权重数组占101KB代码和启动代码占30KB左右。RAM占用更小全局buffer加起来不到12KB加上栈、堆和其他外设缓冲总共也就20KB出头。F407的192KB RAM还剩一大半后续想加摄像头图像缓冲区或者USB协议栈都很宽裕。如果你的工程编译出来RAM占用异常高大概率是某个大数组被定义成了局部变量或者编译器把const数组放到了可读写数据段而不是Flash段。检查一下.map文件看大数组是不是在RO Data段如果跑到了RW Data段说明丢失了const限定符Flash烧录时会被复制到RAM里白白浪费RAM。4.4 用CMSIS-NN进一步加速的进阶路线如果你想挑战性能极限可以用ARM官方的CMSIS-NN库替代手写卷积。CMSIS-NN提供了一系列针对Cortex-M优化的神经网络算子比如arm_convolve_HWC_q7_basic、arm_max_pool_q7_HWC、arm_fully_connected_q7_opt。这些算子的原理其实和我手写代码类似但做了很多底层优化比如利用DSP指令做向量化乘加、对缓冲区做16字节对齐以便使用SIMD读取、对全连接层做im2col预重排等。使用前需要注意内存对齐要求比如输入行数必须是4的倍数之类的限制不然会进入慢速路径甚至跑出错误结果。不过对当前项目来说手写算子已经够快了。CMSIS-NN更适合后续做一些更复杂的项目比如图像分类、关键词唤醒、手势识别这些都是我这套流程的自然延伸。代码移植的思路完全一样训练模型、导出权重、接上算子、调试scale。5. 常见问题与调试实录5.1 训练集准确率98%上板子识别率惨不忍睹这是我被问得最多的问题也是很多第一次做MCU推理的人必然遇到的阶段。PC上模型测试集准确率98.5%烧到板子上拿摄像头拍的数字识别率可能不到50%甚至每次结果都不稳定。原因基本都出在“训练时的输入分布”和“部署时的输入分布”不一致上。MNIST的数据集是28x28、居中、黑底白字、没有噪声。而摄像头拍到的图数字可能不在画面中心、大小不一、有阴影、有背景纹理、有反光直接缩放到28x28后网络看到的东西和训练数据差别巨大。解决思路有两条。一是部署前预处理做充分灰度化、二值化、用连通域或轮廓框找到数字的包围盒裁剪后缩放到28x28尽量让数字居中、充满画面。二是训练时做数据增强随机平移几个像素、随机缩放、随机加一点噪声让模型适应更真实的条件。对于手写数字识别最稳妥的做法是两步结合。特别是“把数字缩放到28x28”要注意保持宽高比不要拉伸变形。“数字居中”可以通过计算数字像素的质心然后平移到图像中心。5.2 代码能编译但一运行就HardFaultHardFault是这个项目最常见的崩溃方式。根据我的经验基本上就三种可能第一种是栈溢出。F407默认的启动文件里栈大小是1KB如果你把784字节的输入数组或者更大的中间buffer放在main函数里一个函数调用嵌套下来栈就爆了。解决办法是把大数组定义成static或全局变量。第二种是数组越界。手写卷积层时特别容易写错边界条件比如padding为1时for循环边界算错一位访问了输入数组之外的内存。排查办法是在关键循环里加边界打印或者用J-Link调试器在HardFault处查看PC指针跳到哪个函数。第三种是内存对齐问题。如果用了CMSIS-NN库的某些优化算子对输入输出的内存对齐有要求比如16字节对齐不满足会执行到对齐异常。手写代码一般不存在这个问题但如果你后续引入DSP库函数就要注意用__ALIGNED(16)声明数组。5.3 推理速度比别人慢一倍问题出在哪同样的网络有人说1毫秒有人测出来10毫秒差异主要来自编译优化选项。MDK里默认的Debug配置是-O0即不做任何优化代码纯粹按源码顺序执行那速度确实会慢很多。在Options for Target - C/C - Optimization Level里选-O2或-O3推理时间通常能缩短50%以上。还有一个容易被忽视的点如果你在循环里使用了printf做实时输出printf本身就是个巨型性能陷阱。我调试时在关键函数里加printf打印中间结果推理时间从3毫秒直接飙到200毫秒。定位问题可以用串口打印但测性能时一定要把printf关掉只用GPIO翻转加示波器测时间。5.4 输出概率分布很平没有一个明显的高峰如果Softmax输出不是类似[0.01, 0.02, 0.93, ...]这种尖锐分布而是0.1到0.2之间的一片平值说明网络的logits没有被正确缩放大概率是量化scale传递错了。检查方法很简单用PC端Python加载同一个权重对同一张测试图做一次float推理把每一层的输出中间值打印出来再跟单片机端的输出做对比。第一次做量化的人通常会发现第一层卷积的输出就对不上这时候逐层检查scale的定义和传递找到哪一层乘错了。两个平台之间的对比调试是整个量化项目最有效的排错手段。提前把PC端的参考输出保存成数组嵌在C代码里做单元测试能帮你快速定位到底哪层算子写错了。6. 这个项目还能往哪个方向延伸6.1 接上摄像头做一个完整的离线识别原型目前测试阶段用串口发图或者预置数组就能跑通但真正做成产品形态建议接OV2640或OV5640摄像头。STM32F407有DCMI数字摄像头接口可以直接采集RGB565图像。采集后先做RGB转灰度再缩放、二值化、找数字轮廓、裁剪到28x28整个预处理流程用DMA传输CPU占用率很低。图像缩放的算法建议先用最近邻跑通后再换成双线性插值。双线性效果更好但计算量也更大。对28x28这种小分辨率即使双线性插值也不会成为瓶颈。6.2 用USB虚拟串口或网络接口传输识别结果F407的USB OTG支持虚拟串口功能把识别结果通过USB发到上位机比UART方便很多不用外接USB转TTL模块。如果你做了联网需求F407也可以接LAN8720以太网模块把识别结果通过UDP协议打包发到服务器或者手机端。这些外设接口的驱动代码都是现成的集成到项目里难度不大。6.3 从MNIST到自定义字符集不要局限于0到9的数字。把MNIST换成EMNIST字母集网络结构基本不用改最后输出层改成26类就能识别手写字母。如果要识别中文因为类别太多模型需要加大。这个扩展方向很适合做课程设计或者毕业设计工作量不大但看起来完整度很高。我在实际调试这个项目时有一种很深的体会神经网络在PC上跑矩阵运算时你可以把一切都交给框架但一旦搬到MC本文还有配套的精品资源点击获取