
简介面向GD32H737平台的多目标实时识别系统完整工程适合嵌入式AI与无人机视觉开发者解决小目标检测与多目标跟踪的速度和精度平衡问题。系统在GD32H737上部署利用OV2640摄像头采集周围图像经神经网络模型推理后将识别结果显示在LCD屏幕并通过UART与外部设备交互实测识别精度约95%多目标跟踪速度最高可达9fps。压缩包共795个文件以C源码、头文件、编译中间文件及模型配置为主并包含工程配置文件与链接脚本整体约27.55MB目录结构清晰便于二次开发。当前已有76人学习浏览。实验基于VisDrone数据集小目标mAP较原模型提升122%mAP50提升73%用500张车辆图片训练识别模型可帮助读者理解从训练到嵌入式部署的完整流程。1. 用GD32做多目标实时识别先把“跑不跑得动”这个问题想清楚一个朋友问我GD32上能不能做多目标实时识别。我直接跟他说能但你不能按上位机的思路来。把目标限定成颜色分明的工件或色块分辨率压到QCIF用颜色阈值加连通域标记GD32完全能在一帧图像到来前处理完并输出多个目标的坐标。这个结论不玄学后面每一步都可以算出来。这套做法适合谁手里有GD32开发板想在设备里加一个低成本视觉节点的嵌入式工程师典型场景是产线小工件分拣、巡线小车避障、简单目标计数。我会从实时性预算、图像采集链路、识别算法、最小工程到调试避坑完整讲一遍。新手能照着复现老手可以重点看第5章的调试经验。2. 方案选型与实时性预算写代码前先把账算明白2.1 为什么走MCU而不是MPU目标集越窄方案越稳很多人一听“识别”就想到树莓派、RK3588、YOLO。但真实场景里大量识别任务的目标集窄得可怜三种颜色的塑料件、一张固定底色的标签、一条传送带上的药瓶。这种任务用MPU是杀鸡用牛刀成本高、启动慢散热和体积都麻烦。GD32这类MCU上电即跑外设直接可控适合做成一个“像素级传感器”嵌在设备里。MCU做识别的短板同样明显没有大内存、没有浮点加速、没有操作系统托管内存。因此不能靠模型规模取胜只能靠把任务约束住。常见做法是选GD32F450/F470这类带数字摄像头接口DCI和足够SRAM的型号把识别目标限定为颜色和形状明确的物体。选型时重点看三样主频、SRAM、摄像头接口。GD32相关型号主频能到200MHzDCI接口又能直接挂OV2640这类摄像头算力和外设都不缺。如果项目顺带对国产化有要求GD32本身就是常见选择这是另一个加分项。但真正决定成败的不是芯片品牌而是你肯不肯把“识别”这个模糊的需求收敛成一个具体、可测的视觉任务。2.2 实时性的账下一帧到来之前处理完当前帧先说“实时”的定义。摄像头输出30fps时帧周期约33.3ms。系统能在下一帧到来之前处理完当前帧就算实时做不到就降帧率到15fps把预算放大到66ms代价是响应变慢。这个“死线”是后面所有代码和参数的基准。我用一段简单代码估算处理耗时#include stdint.h #define CAM_W 176 #define CAM_H 144 #define CAM_FPS 30 #define MCU_MHZ 200 /* 经验系数普通C循环里做阈值判断约3~5个时钟周期处理一个像素 */ #define CYCLE_PER_PIX 4.0f float estimate_process_time_ms(void) { return (float)(CAM_W * CAM_H) * CYCLE_PER_PIX / (MCU_MHZ * 1000.0f); }这段代码做的事情就是“像素数乘以单像素开销再除以主频”。QCIF是176×144共25344像素200MHz下纯计算乐观估计约0.5ms。加上数组读写、函数调用、连通域标号实际放大5到10倍也就几毫秒。这说明一个关键结论瓶颈不在“逐像素扫一遍”而在标号合并、除法、动态分配这类隐藏开销。写算法时优先用整数运算少用float尤其不能在二值图扫描循环里做除法。帧预算不是全部给算法。采集DMA在后台搬数据CPU做识别主循环还要输出结果预算要分三块。算法处理必须占大头但也不能吃满留20%余量给中断和其他任务。假如算下来处理耗时接近甚至超过帧周期不要急着换主控先砍分辨率再降帧率最后才考虑砍算法复杂度。这个优先级由“帧间隔是死线”推出来死线不变只能减少工作量。2.3 分辨率和颜色空间三个参数决定后面所有代码分辨率不是越高越好而是取决于目标在画面里的最小尺寸。一个工件在QCIF画面里至少占10×10像素QCIF就够用如果目标只有4×4像素连通域和面积过滤都会失效再快的算法也救不回来。我一般按下面这张表选分辨率单帧像素数30fps单帧预算建议目标最小尺寸适用场景QCIF 176×1442534433ms5×5像素以上多数MCU视觉首选QVGA 320×2407680033ms8×8像素以上目标较小且算力富余VGA 640×48030720033ms16×16像素以上不建议在GD32上做实时颜色空间同样影响后续代码。DCI摄像头输出YUV422或RGB565YUV422每2个像素用4字节U/V色度是两像素共用做颜色判断要用U/V分量而不是Y亮度。RGB565每个像素2字节颜色直观但和DCI的YUV输出之间要多一次转换。常见做法是直接用YUV422把转换时间省掉。时钟初始化是这一阶段最容易出错的一步。用厂商提供的图形化配置工具类似Cube的gd32配置工具生成工程或者直接抄官方例程里的system_gd32f4xx.c能把主频跑错的问题提前排掉。我一般会先把PLL倍频系数核对一遍再写外设代码否则后面所有帧率测试都是白做。3. 图像采集链路DCI接口、双缓冲与DMA中断节奏3.1 摄像头接线与格式选择OV2640配DCI的常规接法摄像头选型上OV2640和OV7670是最常见的两个。OV2640输出能力更强常用在QCIF/QVGA下的YUV422或RGB565OV7670便宜但寄存器配置繁琐。接线没有太多悬念8位并行数据D0-D7接MCU的DCI数据脚PCLK接像素时钟VSYNC和HSYNC分别接帧同步和行同步SCCB类似I2C用来写摄像头寄存器。具体引脚映射一定要查所用手册的复用表不同封装差异很大。这里有一个容易忽略的硬件细节摄像头模组的IO电压。有的模组是3.3V有的是1.8V核心电压加3.3V IO接错轻则图像异常重则烧引脚。上电顺序也讲究先给摄像头供电再初始化SCCB最后才开DCI时钟。我习惯把摄像头供电和复位引脚单独用两个GPIO控制软件里按“上电→延时50ms→复位→延时20ms→配置寄存器”的顺序走能省掉很多奇怪问题。格式选择上如果只是做颜色阈值识别YUV422比RGB565更合适。YUV422不需要转换就能直接取Y、U、V做判断RGB565则要拆位。后面第4章的阈值判断代码就是基于YUV422写的。3.2 双缓冲把采集、处理、输出重合成流水线单缓冲的缺陷很明显DMA把一帧写入唯一缓冲区CPU处理时必须暂停采集否则数据被覆盖处理完再等下一帧整个帧间隔都浪费了。双缓冲乒乓缓冲的做法是DMA写入buffer A时CPU处理buffer B下一帧DMA写buffer BCPU处理buffer A帧间隔被有效利用。这是嵌入式视觉的常见做法也是实时性预算能成立的前提。缓冲大小要提前算好。QCIF的YUV422每帧176×144×250688字节双缓冲两份就是约100KB。选型时要确认GD32芯片的SRAM余量F4系列大容量版本能扛得住如果SRAM紧张可以改“半帧缓冲分块处理”但代码复杂度会明显上升。我一般先上双缓冲跑通后再优化内存不要一上来就做分块。3.3 DCI初始化与DMA配置用半传输中断实现乒乓切换下面这段代码以GD32F4xx标准外设库的命名风格为参考不同库版本函数名略有差异逻辑是通用的#define FRAME_W 176 #define FRAME_H 144 #define FRAME_BUF_SIZE (FRAME_W * FRAME_H * 2) /* YUV422: 2字节/像素 */ static uint8_t frame_buf[2][FRAME_BUF_SIZE] __attribute__((aligned(32))); static volatile uint8_t frame_ready_idx 0xFF; /* 0/1表示可处理帧0xFF表示无 */ void dci_dma_init(void) { rcu_periph_clock_enable(RCU_DCI); rcu_periph_clock_enable(RCU_DMA1); /* PCLK上升沿采集VSYNC/HSYNC极性按摄像头输出配置 */ dci_polarity_config(DCI_PCLK_RISING, DCI_VSYNC_LOW, DCI_HSYNC_LOW); dci_parameter_config(DCI_MODE_SNAPSHOT, DCI_FMT_YUV422, DCI_BURST_LENGTH_8); dci_dma_enable(); dci_enable(); dma_deinit(DMA1, DMA_CH0); dma_parameter_struct dma; dma_struct_para_init(dma); dma.periph_addr (uint32_t)DCI_DATA; /* 外设数据寄存器 */ dma.memory_addr (uint32_t)frame_buf[0]; dma.direction DMA_PERIPH_TO_MEMORY; dma.memory_width DMA_MEMORY_WIDTH_16BIT; dma.periph_width DMA_PERIPH_WIDTH_16BIT; dma.number 2 * FRAME_BUF_SIZE / 2; /* 半字数量两块缓冲一次配完 */ dma.priority DMA_PRIORITY_HIGH; dma_init(DMA1, DMA_CH0, dma); dma_circulation_enable(DMA1, DMA_CH0); /* 循环模式 */ dma_interrupt_enable(DMA1, DMA_CH0, DMA_INT_HTF | DMA_INT_FTF); dma_enable(DMA1, DMA_CH0); } void DMA1_Channel0_IRQHandler(void) { if (dma_interrupt_flag_get(DMA1, DMA_CH0, DMA_INT_FLAG_HTF)) { frame_ready_idx 0; /* 前半块传完对应 buf[0] */ dma_interrupt_flag_clear(DMA1, DMA_CH0, DMA_INT_FLAG_HTF); } else if (dma_interrupt_flag_get(DMA1, DMA_CH0, DMA_INT_FLAG_FTF)) { frame_ready_idx 1; /* 后半块传完对应 buf[1] */ dma_interrupt_flag_clear(DMA1, DMA_CH0, DMA_INT_FLAG_FTF); } }DCI参数里PCLK极性按摄像头手册调OV2640数据通常在上升沿稳定极性配错会出现图像“拉丝”。DMA传输宽度配16bit半字因为YUV422两像素组成一个半字配成8bit也能工作但效率低且字节序容易错。DMA开启循环模式后HTF和FTF两个中断分别表示前半块和后半块传完硬件自动回绕不需要在中断里手动改内存地址。主循环的处理逻辑配合一个“帧令牌”int main(void) { system_clock_config(); /* 先把主频跑对 */ dci_dma_init(); camera_sccb_init(); /* 配置OV2640输出QCIF YUV422 */ while (1) { if (frame_ready_idx ! 0xFF) { uint8_t idx frame_ready_idx; frame_ready_idx 0xFF; process_frame(frame_buf[idx]); /* 识别算法入口 */ } } }frame_ready_idx用0xFF表示“没有新帧”是一个极简的信号量。处理完一帧后清掉避免重复处理。这个方案隐含一个前提处理耗时小于帧周期。一旦超时DMA循环模式会直接覆盖正在处理的缓冲现象是图像每隔几帧闪一下、识别结果时好时坏。这时候不要改缓冲逻辑回到第2章把预算重新砍一遍。调采集链路时最典型的一类gd32调试问题是DMA中断标志没清干净导致IRQ里反复进入、主循环卡死。清除标志要放在读标志之后而且HTF和FTF要分别清不能图省事只清一个。4. 多目标识别算法颜色阈值、连通域标记与目标去重4.1 算法选型为什么是“颜色连通域”而不是跑网络在GD32上跑神经网络不是完全没可能但实时性很难看。哪怕是最简化的量化模型一次前向也要数百兆次乘加运算200MHz的MCU算完一帧基本超过帧间隔。所以这条路上颜色阈值加连通域标记才是性价比最高的方案。这个方案的本质是“只针对目标颜色做实例分割”先判断每个像素是不是目标颜色得到二值图再用连通域把相邻的目标像素聚成块最后用面积、质心、位置去重输出。它不识别形状只回答“哪里有目标颜色、目标有多少个”。适用前提是目标颜色与背景差异明显、光照相对稳定。光照一乱再好的阈值也白搭常见做法是加固定补光或者做白平衡。算法链定下来后后面每一步都可以拆成可测试的模块YUV422转二值图、连通域标记、面积过滤、质心计算、去重输出。每个模块单独调比整体调试快得多。4.2 从YUV422到二值图色度阈值怎么标定YUV422的数据排列是每4字节存2个像素Y0 U0 Y1 V0。两个像素共用U和V这对颜色判断来说足够了。以下代码以“蓝色目标”为例static int is_target_color(uint8_t y, uint8_t u, uint8_t v) { /* 蓝色在U/V色度平面有特征阈值需要实测标定 */ return (u 90 u 130 v 60 v 110) ? 1 : 0; } void make_binary_from_yuv422(const uint8_t *src, uint8_t *dst, int w, int h) { int total w * h / 2; /* 每2像素一组共w*h/2组 */ for (int i 0; i total; i) { const uint8_t *p src i * 4; uint8_t y0 p[0], u p[1], y1 p[2], v p[3]; dst[i * 2] is_target_color(y0, u, v) ? 255 : 0; dst[i * 2 1] is_target_color(y1, u, v) ? 255 : 0; } }参数说明U和V的阈值是场景相关的不是随便抄一组就能用。我一般会先采集一帧纯色目标的画面把目标区域的U/V范围打印出来再留10到20的余量。Y通道不要参与颜色判断亮度一变二值图就翻车这是最容易踩的坑。注意U/V阈值换灯光、换摄像头后要重新标定不要迷信一组参数走天下。二值图大小为w×h字节QCIF下约25KB可以复用或单独申请。如果目标是多色的二值图方案可以扩展成多组阈值、多个二值图分别跑连通域但处理时间会线性增加建议先用单色验证整个链路。4.3 连通域标记两遍扫描的MCU实现二值图里有大量噪点需要把相邻的目标像素连通成块。经典算法是两遍扫描加Union-Find合并等价标号。下面给出一个可用的MCU版本#define MAX_LABELS 64 static int16_t label_map[176 * 144]; /* 每像素一个标号 */ static int16_t union_tbl[MAX_LABELS]; int16_t find_root(int16_t x) { while (union_tbl[x] ! x) { union_tbl[x] union_tbl[union_tbl[x]]; /* 路径压缩 */ x union_tbl[x]; } return x; } void merge_label(int16_t a, int16_t b) { int16_t ra find_root(a), rb find_root(b); if (ra ! rb) union_tbl[rb] ra; } void first_pass(const uint8_t *binary, int w, int h) { for (int y 0; y h; y) { for (int x 0; x w; x) { if (!binary[y * w x]) { label_map[y * w x] 0; continue; } int16_t left (x 0) ? label_map[y * w x - 1] : 0; int16_t up (y 0) ? label_map[(y - 1) * w x] : 0; if (left 0 up 0) { label_map[y * w x] next_label; } else if (left ! 0 up 0) { label_map[y * w x] left; } else if (left 0 up ! 0) { label_map[y * w x] up; } else { label_map[y * w x] left; merge_label(left, up); } } } } void second_pass(const uint8_t *binary, int w, int h) { static int area[MAX_LABELS]; static long sum_x[MAX_LABELS], sum_y[MAX_LABELS]; for (int i 0; i MAX_LABELS; i) { area[i] 0; sum_x[i] 0; sum_y[i] 0; union_tbl[i] i; } for (int y 0; y h; y) { for (int x 0; x w; x) { int16_t root find_root(label_map[y * w x]); label_map[y * w x] root; if (root 0) { area[root]; sum_x[root] x; sum_y[root] y; } } } }label_map用int16_t每像素2字节QCIF约50KB。MAX_LABELS设64意味着最多允许64个连通区域这个值要按场景调整目标多或噪点多时要加大。union_tbl数组的下标就是标号越界会写坏内存这是连通域方案最典型的踩坑点。第二遍扫描里同时统计面积和质心避免再开一遍循环。质心是sum_x除以area但要小心除法建议只在最后输出时做一次不要在像素循环里做。如果SRAM紧张可以把label_map换成行程编码加Union-Find的方案内存能降到帧宽的KB级但代码复杂度明显上升建议先跑通两遍扫描再说。4.4 多目标去重与结果输出连通域可能把同一个物体拆成好几块比如工件上有一条高光或者目标被遮挡出一小段缝隙。这时候需要按质心距离做合并typedef struct { uint16_t x, y; uint16_t area; } obj_t; #define MAX_OBJS 8 #define MIN_AREA 30 /* 小于该面积按噪点丢弃 */ #define MERGE_DIST 5 /* 质心距离小于该值的两个目标合并 */ int merge_objs(obj_t *in, int n, obj_t *out) { int m 0; for (int i 0; i n; i) { if (in[i].area MIN_AREA) continue; if (m 0 abs(in[i].x - out[m - 1].x) MERGE_DIST abs(in[i].y - out[m - 1].y) MERGE_DIST) { /* 面积加权合并质心 */ out[m - 1].x (out[m - 1].x * out[m - 1].area in[i].x * in[i].area) / (out[m - 1].area in[i].area); out[m - 1].y (out[m - 1].y * out[m - 1].area in[i].y * in[i].area) / (out[m - 1].area in[i].area); out[m - 1].area in[i].area; } else { out[m] in[i]; } } return m; }MIN_AREA用来过滤噪点面积阈值太小会把背景噪点当目标太大又会把真实目标丢掉。MERGE_DIST取5像素时两个相邻但仍独立的同类工件不会误合并如果工件在画面里偏大这个值要跟着加大。合并函数里假设输入已经按x坐标排序如果不排序就要做全量两两合并或者先排序。最后输出最多MAX_OBJS个目标超出的直接丢弃避免下游逻辑被海量目标打爆。5. GD32工程实操避坑记录从vector table到DMA的5个坑5.1 程序跳不到Appvector table base offset没有跟着搬家现象从Bootloader跳转到App后点灯正常但一开定时器或串口中断就HardFault或者完全无响应。原因App的向量表地址还留在默认的0x08000000。内核响应中断时要读“向量表地址中断号×4”读到的还是Bootloader里的ISR地址执行流彻底错乱。这个坑在带Bootloader的工程里几乎必踩。解决在App的main最前面设置向量表偏移#define APP_BASE_ADDR 0x08020000 /* 按App实际烧录地址改 */ void app_init(void) { SCB-VTOR APP_BASE_ADDR; __DSB(); }GD32部分型号的“vector table base offset”要求按0x200或0x40字节对齐偏移量写错导致的后果同样是进中断即HardFault。设置后用调试器单步确认SCB-VTOR已生效再开中断。5.2 下载没问题一跑就HardFault中断名和NVIC优先级现象烧录正常main能进一开DCI或DMA中断就HardFault。原因最常见的有三个外设时钟没开全中断服务函数名和启动文件的命名不一致中断触发后跳到了一个不存在或错误的地址NVIC优先级没配置中断嵌套乱套。这类gd32调试问题在手动建工程时出现率极高。解决先不开中断用轮询DCI标志位的方式把采集跑通确认数据通路没问题再开中断打开启动文件逐个核对IRQHandler名字。用类似Cube的图形化配置工具生成工程时IRQ函数名是自动匹配的手动建工程最容易在这里踩坑。DCI和DMA的中断要分别使能漏一个就是“标志位已经置位但中断不进”。5.3 图像花屏、偏色DMA宽度、地址对齐和PCLK极性现象图像看起来像被左右撕裂或者整体偏色严重时一条条错位。原因常见有三种。DMA传输宽度和YUV422数据长度不匹配缓冲地址没有按DMA要求的边界对齐DCI像素时钟极性配反。解决DMA宽度配成16bit半字和YUV422两像素一组的结构对应缓冲数组加上__attribute__((aligned(32)))PCLK极性翻过来试一次。调试时可以先让摄像头输出纯色测试图或者固定抓一帧把原始YUV422数据通过串口/上位机存成文件看。否则摄像头就是一个黑匣子很难判断是采集问题还是算法问题。5.4 跑几分钟无故复位堆栈溢出和标号越界现象系统运行几分钟后复位没有任何规律有时出现在目标数量多的画面。原因连通域标记里的label_map和area数组如果放在函数内作为局部变量栈会被瞬间撑爆。union_tbl数组下标越界也会写坏内存触发HardFault后看门狗复位。这类问题最隐蔽因为它不是必现的。解决把大数组移到函数外声明为全局或静态变量启动文件里把堆栈加大Stack_Size EQU 0x2000同时给目标数量加硬限制防止标号超过数组范围。HardFault出现时查LR和PC的调用栈回溯源能快速判断是栈溢出还是越界写坏。5.5 帧率只有几帧主频配置错了所有时序全乱现象摄像头能出图但帧率只有个位数串口数据也是乱的怎么看都不对劲。原因系统主频没有按外部晶振走PLL而是跑在内部IRC或其他错误分频上。主频一旦翻车时钟树里所有外设的波特率、像素时钟、定时周期会一起错但点灯这类操作又看不出来。解决核对system_gd32f4xx.c里的时钟宏和外部晶振频率确认__SYSTEM_CLOCK的目标值再用逻辑分析仪量GPIO翻转频率做验证。主频错误是所有gd32问题里最隐蔽的一个它不影响“能跑”只影响“性能对不对”。先确认主频再谈实时性。6. 把系统调到“真实时”性能压测与ROI动态裁剪6.1 用GPIO翻转量化每一段耗时帧率不是感觉出来的要用示波器量。最常见的做法是在关键函数前后翻转一个GPIOgpio_bit_set(GPIOA, GPIO_PIN_0); process_frame(frame_buf[idx]); gpio_bit_reset(GPIOA, GPIO_PIN_0);然后把GPIOA的PIN_0接示波器量高电平持续的时间就是process_frame的耗时。我一般会在二值化、连通域、合并输出三段各放一个翻转点分别看时间占比。这样优化起来有依据哪段宽就砍哪段而不是靠猜。6.2 ROI动态裁剪用上一帧位置缩小扫描范围固定背景场景下目标不会瞬移。可以记录上一帧每个目标的质心在下一帧只在质心周边扩大一个边界的窗口内做二值化和连通域其余区域直接跳过。窗口大小按目标最大运动速度估算#define MARGIN 10 void update_roi(const obj_t *last, int img_w, int img_h) { roi_x0 (last-x MARGIN) ? last-x - MARGIN : 0; roi_y0 (last-y MARGIN) ? last-y - MARGIN : 0; roi_x1 (last-x MARGIN img_w) ? last-x MARGIN : img_w - 1; roi_y1 (last-y MARGIN img_h) ? last-y MARGIN : img_h - 1; }MARGIN要根据目标在画面里的运动速度定目标每帧最多移动N像素MARGIN就取1.5N。目标丢失时回退到全图扫描几帧找到后再切回ROI模式。我之前有一版直接上QVGA全分辨率、全图连通域帧率不到15fps后来把分辨率降到QCIF并只在上一帧目标位置周边做ROI扫描稳定跑在30fps。这种“先把预算砍下来再谈精度”的做法是我做MCU视觉最大的血泪经验。希望帮到你。本文还有配套的精品资源点击获取