STM32H7 上实时推理 SSD 目标检测网络【开源】
【开源】在 STM32H7 上实时推理 SSD 目标检测网络
📁 项目已开源: Flora233333/Deploy-SSD-ON-STM32
📺 B 站演示视频: (开源+教程)在STM32上实时推理SSD目标检测网络🧐
硬件: STM32H743 + OV5640 摄像头 + SPI LCD
关键词:X -CUBE-AI、SSD、MobileNetV1、uint8 量化、DMA2D、stm32ai-modelzoo、后处理
写在前面:
这是上一篇 MobileNetV2 分类文章的姐妹篇
上次尝试了 MCU 上跑"图像分类"是可行的,这次我想 在一块单片机上跑一个轻量的 SSD 目标检测网络 ,而且要接上摄像头做实时推理
这个项目是我 2025 年初折腾的,做完在 B 站发了视频之后打算写一篇简单的介绍文,拖到现在才动笔 🤪
烦请多多担待啦!
如果帮助到您的话,可以给我点一个 Star⭐吗!
经常能看到这样的问题:
- “MCU 真的能跑目标检测吗?分类都嫌费劲了。”
- “后处理那么多浮点,MCU 处理速度如何?”
作为一个当时天天在各种 MCU 上打比赛折腾的研究生大学生,我想尝试一下
于是就有了这个项目——在一块 STM32H750 上,跑一个 SSD 目标检测网络,并且接上摄像头做实时推理。
最终的效果是这样的:摄像头对着检测目标,屏幕上实时框出目标位置并打印单次推理延迟。测试集 mAP 31.8%,单帧端到端 ≈ 240 ms(约 4-5 FPS),其中推理本体约 235 ms。
一、硬件选型
这次项目用的 MCU 是 STM32H750XBH6
| 参数 | 数值 |
|---|---|
| 内核 | Cortex-M7 + FPU + DSP |
| 主频 | 480 MHz |
| Flash (ROM) | 2 MB |
| RAM | 1 MB(含 DTCM / AXI SRAM) |
| 图形加速 | Chrom-ART(DMA2D)✅ |
| AI 加速 | 无专用 NPU |
选它的理由相比上一次更明确:
1. 目标检测的权重 + 算子代码比分类大得多,必须要有 2 MB Flash
2. DMA2D 这次不是可选项,是必选项(后面会说为啥)
3. 手头只有这个😋
H750XBH6 片上虽然只标 128 KB Flash,但实际它和 H743XI 是同一片硅,有完整的 2 MB Flash,ST 只是为了产品分级好像是没对多出来的Flash进行检测,但性价比很高。
外设搭配(和上一篇几乎一致):
- OV5640 摄像头:500 万像素,带硬件自动对焦,DCMI 接口
- SPI LCD:2.4 寸 TFT,作为结果显示
- 用到的 MCU 外设:DCMI、DMA、DMA2D、SPI、CRC、USART、GPIO
相比上一篇分类项目,这次外设清单里多了一个 DMA2D——因为目标检测的预处理要三通道并行搬运,LUT 查表不再是最优解,后面会详细讲。
二、模型选型:为什么选 SSD,不选 YOLO?
做 MCU 端目标检测,绕不开的第一个问题是:选什么网络。
我相信 99% 的人第一反应都是 YOLO。但如果你真的去翻 YOLOv3 以后的后处理代码——grid cell、多尺度 anchor、Sigmoid / Exp 解码、再加 NMS,全是浮点和分支。X-CUBE-AI 的算子支持又有限,很多东西得自己在 C 里重写一遍,光后处理就能把人写到怀疑人生。
SSD(Single Shot MultiBox Detector)结构要"钝"得多,也正因为钝,它对 MCU 极度友好。
我用的是 ST 官方的 st_ssd_mobilenet_v1(就在 stm32ai-modelzoo 里),它的三个输出头排布非常规整:
| 输出 | Shape | 含义 |
|---|---|---|
cls |
(1, 3830, 2) | 每个 anchor 的两类置信度(背景 + 目标) |
boxes |
(1, 3830, 4) | 每个 anchor 的回归偏移 ((\Delta x, \Delta y, \Delta w, \Delta h)) |
anchors |
(1, 3830, 4) | 每个 anchor 的先验框 ((x_{min}, y_{min}, x_{max}, y_{max})) |
SSD 的解码公式也简单到可以一行写完:
x p r e d = x a n c h o r , m i n + Δ x ⋅ ( x a n c h o r , m a x − x a n c h o r , m i n ) x_{pred} = x_{anchor,min} + \Delta x \cdot (x_{anchor,max} - x_{anchor,min}) xpred=xanchor,min+Δx⋅(xanchor,max−xanchor,min)
score i = σ ( cls_logit i ) \text{score}_i = \sigma(\text{cls\_logit}_i) scorei=σ(cls_logiti)
最终选的模型规格:
- backbone:MobileNetV1(纯卷积,量化友好)
- Input shape😦(192, 192, 3)),RGB888
- 输入端:
uint8(和上期分类模型一致,方便预处理) - 输出端:
float(方便直接做解码 + NMS) - Total params:434,814
- Total layers:174
经验之谈:MCU 上部署目标检测,模型有没有"量化友好"非常关键。YOLO 里的 Focus、SiLU 这些对 uint8 量化其实不太友好;SSD + MobileNetV1 量化到 uint8 后 mAP 掉得不算离谱,非常适合端侧。另外 ST 官方 modelzoo 里的
st_ssd_mobilenet_v1已经给你做了不少端侧裁剪,直接用比从零改网络省事太多。
2.1 训练配置
训练基于 ST 官方的 stm32ai-modelzoo 仓库改的,object_detection/src/user_config.yaml 配好就能一键跑:
general:
project_name: card_project
model_type: st_ssd_mobilenet_v1
gpu_memory_limit: 4
global_seed: 42
operation_mode: chain_tqe # training → quantization → evaluation
dataset:
name: card
class_names: [ card ]
training_path: ./dataset/train
validation_path: ./dataset/valid
quantization_split: 0.2
preprocessing:
rescaling: { scale: 1, offset: 0 } # 这里为了部署方便不做归一化
resizing:
aspect_ratio: fit
interpolation: nearest
color_mode: rgb
data_augmentation:
rotation: 30
shearing: 15
translation: 0.1
vertical_flip: 0.5
horizontal_flip: 0.2
gaussian_blur: 3.0
linear_contrast: [ 0.75, 1.5 ]
几个非常重要的点:
① rescaling 保持 scale: 1, offset: 0。训练时如果你做了 / 127.5 - 1 这种归一化,部署时 MCU 端每一帧都得重复这个操作,白白多掉十几毫秒。直接让模型吃 uint8 原始像素,训练侧为部署让路——嵌入式场景里,这是铁律。
② 数据集必须是 YOLO 格式:一张图配一个 .txt,每行 class cx cy w h(全部归一化到 ([0, 1]))。
③ operation_mode: chain_tqe 是 modelzoo 的链式模式,一口气做完训练 → 量化 → 评估,量化后的 tflite 直接可以丢进 X-CUBE-AI,不用再手动转一遍。对新手非常友好。
其他超参:
- 框架:Keras
- 优化器:Adam,学习率 (1 \times 10^{-3})
- 训练轮次:500 epochs
- 数据集:自己采集的扑克牌图像,约两三千张
单卡 3060 跑两小时收敛,loss 从 110 一路降到个位数。
启动训练就一行:
cd object_detection/src
python stm32ai_main.py
2.2 模型转换链路
整条端到端链路:
.h5 (Keras) → modelzoo chain_tqe .tflite (PTQ uint8) → X-CUBE-AI C source + weights \texttt{.h5 (Keras)} \xrightarrow{\text{modelzoo chain\_tqe}} \texttt{.tflite (PTQ uint8)} \xrightarrow{\text{X-CUBE-AI}} \texttt{C source + weights} .h5 (Keras)modelzoo chain_tqe.tflite (PTQ uint8)X-CUBE-AIC source + weights
这里有一个和上一篇分类项目共通的小决策——输入端 uint8 量化、但输出端保留 Float。原因很简单:SSD 的解码需要精确的浮点坐标,如果连输出都量化,边框坐标会掉得很惨。这个"输入量化 / 输出浮点"的搭配模式,基本可以套到 90% 的端侧视觉模型上。
三、工具链:X-CUBE-AI 生成了什么
(上一篇详细讲过,这里简要带过)
X-CUBE-AI 是 STM32CubeMX 的一个插件,它会自动:
- 分析算子,生成 C 代码(
network.c / network.h / network_data.c / network_data_params.c); - 打包权重成常量数组;
- 给出 RAM / Flash 占用评估和逐层延迟报告(调性能可以仔细研究下)
生成的 API 核心还是三步,代码在 X-CUBE-AI/App/app_x-cube-ai.c:
/* 1. 句柄初始化与内存分配 */
static int ai_boostrap(const ai_handle *act_addr)
{
err = ai_network_create_and_init(&network, act_addr, NULL);
if (err.type != AI_ERROR_NONE) {
ai_log_err(err, "ai_network_create_and_init");
return -1;
}
ai_input = ai_network_inputs_get (network, NULL);
ai_output = ai_network_outputs_get(network, NULL);
/* 把分配好的 input / output buffer 指针挂给句柄 */
#if defined(AI_NETWORK_INPUTS_IN_ACTIVATIONS)
for (int idx = 0; idx < AI_NETWORK_IN_NUM; idx++)
data_ins[idx] = ai_input[idx].data;
#else
for (int idx = 0; idx < AI_NETWORK_IN_NUM; idx++)
ai_input[idx].data = data_ins[idx];
#endif
for (int idx = 0; idx < AI_NETWORK_OUT_NUM; idx++)
data_outs[idx] = ai_output[idx].data;
return 0;
}
/* 2. 主推理流程:采集 → 推理 → 后处理 → 画框 */
void MX_X_CUBE_AI_Process(void)
{
int res = -1;
if (network) {
res = acquire_and_process_data(data_ins); /* 摄像头 → 192×192 uint8 */
if (res == 0) res = ai_run(); /* 真正跑 inference */
if (res == 0) {
res = post_process(data_outs); /* 解码 + NMS */
if (res > 0) {
/* RGB888 → RGB565 硬件搬运,准备送显 */
Dma2d_Memcpy_PFC((uint32_t *)data_ins[0],
(uint32_t *)Camera_Buffer,
0, 0, 192, 192,
DMA2D_INPUT_RGB888,
DMA2D_OUTPUT_RGB565);
/* 画框 */
for (int i = 0; i < res; i++) {
int x_min = target[i].x_min;
int y_min = target[i].y_min;
int width = target[i].x_max - target[i].x_min;
int height = target[i].y_max - target[i].y_min;
_LCD_DrawRect(x_min, y_min, width, height,
IMG_INPUT_WIDTH, (uint16_t *)Camera_Buffer);
}
}
}
}
}
相比上一篇分类模型,MX_X_CUBE_AI_Process 多做了两件事:
① 解码 3830 个 anchor 并做 NMS;② 用 DMA2D 把 RGB888 转 RGB565 再送显。这两件事就是这篇文章剩下要讲的全部内容。
四、踩过的坑:后处理
这是整个项目我花时间最长的部分。
推理出来的三个 tensor 你不能直接拿来用,必须走一遍标准的 SSD 后处理:
- 遍历 3830 个 anchor,按置信度阈值过滤;
- 按解码公式把 ( Δ x , Δ y , Δ w , Δ h \Delta x, \Delta y, \Delta w, \Delta h Δx,Δy,Δw,Δh) 映射回 ([0, 1]) 区间的框;
- 乘回
IMG_INPUT_WIDTH = 192得到像素坐标; - NMS 去重。
关键代码:
int post_process(ai_i8 *data[])
{
float *scores = (float *)data[0];
float *boxes = (float *)data[1];
float *anchors = (float *)data[2];
int detection_count = 0;
for (int i = 0; i < AI_OBJDETECT_SSD_ST_PP_TOTAL_DETECTIONS; i++) {
float class1_conf = scores[i * 2 + 1];
if (class1_conf < AI_OBJDETECT_SSD_ST_PP_CONF_THRESHOLD)
continue;
if (detection_count >= AI_OBJDETECT_SSD_ST_PP_MAX_PORCESS_LIMIT)
break;
float xmin = boxes[i * 4 + 0];
float ymin = boxes[i * 4 + 1];
float xmax = boxes[i * 4 + 2];
float ymax = boxes[i * 4 + 3];
float anchor_x_min = anchors[i * 4 + 0];
float anchor_y_min = anchors[i * 4 + 1];
float anchor_x_max = anchors[i * 4 + 2];
float anchor_y_max = anchors[i * 4 + 3];
int _xmin = (int)((anchor_x_min + xmin * (anchor_x_max - anchor_x_min)) * IMG_INPUT_WIDTH);
int _ymin = (int)((anchor_y_min + ymin * (anchor_y_max - anchor_y_min)) * IMG_INPUT_HEIGHT);
int _xmax = (int)((anchor_x_min + xmax * (anchor_x_max - anchor_x_min)) * IMG_INPUT_WIDTH);
int _ymax = (int)((anchor_y_min + ymax * (anchor_y_max - anchor_y_min)) * IMG_INPUT_HEIGHT);
/* push 到待 NMS 列表 */
detections[detection_count].x_min = _xmin;
/* ...存 ymin / xmax / ymax / conf... */
detection_count++;
}
return nms(detections, detection_count);
}
这段代码看起来平平无奇,但里面埋了两个让我调了整整两天的坑。
4.1 第一口坑:栈溢出
我一开始把候选框数组开成 int selected[3830],直接扔在函数栈上。
STM32 默认栈才 4 KB,3830 × 4 = 15 KB,板子直接 HardFault。
改法很简单:静态全局 + 限定 MAX_PORCESS_LIMIT = 10。这也是为什么你在代码里会看到 AI_OBJDETECT_SSD_ST_PP_MAX_PORCESS_LIMIT 这个宏——砍候选框本质上不是为了 NMS 效率,是为了让内存活下来。
经验之谈:MCU 上写代码,任何
> 1 KB的数组都要仔细想一下放哪里。优先级大致是:DTCM(最快但最小)> AXI SRAM(大) > 外部 SDRAM。一个很容易踩的误区是"我把它声明成static就万事大吉"——static也会吃 BSS 段的 RAM,链接脚本没配好照样爆。
4.2 第二口坑:坐标系
SSD anchor 坐标是 ([0, 1]) 归一化的,乘回 IMG_INPUT_WIDTH = 192 才能画到 LCD 上。
但我的 LCD 是 240×320 的,直接画 192×192 的框就会定位到屏幕一角,看起来像是模型坏了。我当时排查了半天推理结果,最后才发现是显示坐标系没对齐😅 后来加了一次居中偏移才解决。
经验之谈:只要不是 LCD 分辨率和模型输入 1:1 的场景,先在屏幕上画个十字线标定坐标系,再去调框。能省你一晚上的排查时间。别问我怎么知道的。
五、预处理这次换个思路:DMA2D 直接搬
上一篇分类模型里,我用了一个 256 项的 LUT(pixel_conv_lut[256])来做 RGB565 → RGB888 + 量化,那套方案在单通道上非常优雅。
但这次目标检测 SSD 要保留三通道原始结构,LUT 做起来就很别扭——你得分别给 R / G / B 建三张表,还得考虑 RGB565 三通道位宽不一致的问题(R/B 是 5 bit,G 是 6 bit)。
这时候就该 DMA2D 登场了。
整个图像通路大致长这样:
OV5640 ──DCMI+DMA──► Camera_Buffer (RGB565)
│
├─► SPI LCD 原分辨率显示
│
└─► DMA2D_Memcpy_PFC (RGB565 ⇄ RGB888)
│
├─► ai_input[0] (uint8 × 192 × 192 × 3)
└─► ai_run()
│
└─► post_process ─► _LCD_DrawRect
5.1 DMA2D 加速图像处理
STM32H7 自带的 DMA2D 是一个专门做 2D 图像搬运 + 格式转换的硬件加速器,完全不走 CPU。一次调用就能把一整块 RGB565 转成 RGB888:
Dma2d_Memcpy_PFC(
(uint32_t *)data_ins[0], // 目标:ai_input (uint8 RGB888 排布)
(uint32_t *)Camera_Buffer, // 源: Camera_Buffer (RGB565)
0, 0, 192, 192, // 裁剪区域 + 输出尺寸
DMA2D_INPUT_RGB888,
DMA2D_OUTPUT_RGB565
);
PFC = Pixel Format Conversion,一次调用搞定裁剪 + 格式转换 + 搬运三件事,并且 CPU 在这期间可以去做别的(比如画框或者刷屏)。省下来的时间直接加到帧率上。
这是 H7 相对于其他 MCU 最大的甜点,强烈建议所有做 MCU 视觉的人都把 DMA2D 用起来。
5.2 主循环长这样
SPI_LCD_Init(); // SPI LCD 屏幕初始化
DCMI_OV5640_Init(); // DCMI 以及 OV5640 初始化
OV5640_AF_Download_Firmware();
OV5640_AF_Trigger_Constant(); // 让摄像头持续自动对焦
MX_X_CUBE_AI_Init(); // AI 推理引擎初始化
while (1)
{
if (OV5640_FrameState == 1) // 采集到了新一帧图像
{
OV5640_FrameState = 0;
MX_X_CUBE_AI_Process();
/* 内部依次做:
* 1. 摄像头帧 → 192×192 uint8
* 2. ai_run() 推理
* 3. 解码 + NMS
* 4. DMA2D 转 RGB565
* 5. LCD 刷新 + 画框
* 6. 屏幕角落打印 "235 ms"
*/
}
}
和上一篇一样,AF_Trigger_Constant 的持续对焦让识别时用户随便晃动物体也不会糊,非常实用。
六、最终表现
- 测试集 mAP:31.8%
- 单次推理(
ai_run本体):≈ 235 ms - 单帧端到端延迟:≈ 240 ms(含采集、预处理、推理、NMS、LCD 刷新)
- 端到端帧率:≈ 4 FPS
- Flash 占用:约 1.2 MB / 2 MB(得益于 H750 的"隐藏 Flash")
上一篇分类模型我做到了 12 FPS,这次检测掉到 4 FPS——看起来慢了三倍,但做的事情完全不是一个级别的:3830 个 anchor 的后处理 + 两次 DMA2D 搬运 + 画框显示,已经是 MCU 能做到的相当可观的边界了。
做完项目有人说我:“240 ms 一帧,这也叫实时?”
我觉得关键在于这是 没有任何 NPU 加速的纯 Cortex-M7 软件推理。
对 ADAS、无人机自主避障,这个延迟当然不够;但对于大部分"识别到目标就触发一次动作"的场景——电表上的数字识别、循迹小车的靶标检测、电赛题目里的卡片识别、农业传感器里的害虫检测、玩具的物体识别——大概也许还是能用下吧…
七、一点 TinyML 的思考(再进一步)
如果说分类模型的瓶颈分布是 [70% 推理, 20% 预处理, 10% 其他],那目标检测的瓶颈分布会直接摊平:
- 内存带宽:三个输出 head 合起来几十 KB 浮点,必须分配到 AXI SRAM;
- 像素格式转换:RGB565 → RGB888,CPU 查表和 DMA2D 硬件搬运差 10 ms 起步;
- 后处理:3830 个 anchor 遍历 + NMS,这块就能吃掉 5 ~ 10 ms;
- Cache 配置:ICache / DCache 一定要开,但 DMA 缓冲区要做
SCB_CleanInvalidateDCache_by_Addr,否则画面就花了(这个坑我也踩过); - Flash 布局:权重放内部 Flash 还是 QSPI 外接 Flash?后者可能让推理慢 30%。
这些在 GPU / 服务器上根本不是问题的事情,到了 MCU 上每一项都可能让你帧率腰斩。
所以还是那句话——TinyML 本质上是一门嵌入式系统工程,不是把一个模型转一下就完事。真正能让你帧率翻一倍的,往往不是换一个更小的模型,而是把 DMA2D 用起来、把 Cache 配对、把 buffer 放对位置。
八、如何自己跑起来
仓库的实际目录结构(main 分支):
Deploy-SSD-ON-STM32/
├── model/ # .tflite 模型文件
│ ├── st_ssd_mbv_for_card.tflite
│ └── uint8-float_mobilenetV2-0.35_acc0.972.tflite # 姐妹项目的分类模型
├── deployment on mcu/ # Keil 工程,X-CUBE-AI 已集成
│ └── STM32H743/ # H743 / H750 均可烧录
│ ├── MDK-ARM/ # Keil 工程 (.uvprojx 在这里)
│ ├── Core/ # STM32CubeMX 生成的 HAL 代码
│ ├── Drivers/ # STM32H7xx HAL + CMSIS
│ └── X-CUBE-AI/
│ └── App/
│ ├── app_x-cube-ai.c # 本文重点讲的推理 + 后处理代码
│ └── network*.c/h # 自动生成的算子与权重
├── train/ # 基于 stm32ai-modelzoo 的训练配置
│ └── object_detection/
│ └── src/
│ ├── user_config.yaml
│ └── stm32ai_main.py
└── README.md
复现步骤:
- Clone 仓库;
- Keil 打开
deployment on mcu/STM32H743/MDK-ARM/下的.uvprojx工程,直接烧录可以看到检测效果;
硬件门槛:一块 H750 / H743 核心板 + OV5640 摄像头 + 2.4 寸 SPI 屏
写在最后
项目完整代码、Keil 工程、STM32CubeMX .ioc、X-CUBE-AI 生成的算子代码、训练脚本都已经开源到 GitHub。
希望能给我点一个 Star⭐!
也欢迎看 B 站上我发的演示视频,从最终效果一路讲到训练脚本的 Code Review:
- GitHub 仓库: Flora233333/Deploy-SSD-ON-STM32
- Bilibili 演示视频: (开源+教程)在STM32上实时推理SSD目标检测网络🧐
如果你也在折腾 MCU 上的深度学习部署,欢迎来提 Issue / PR,也欢迎在评论区交流!
谢谢大家了! 🙌
更多推荐


所有评论(0)