1. 背景与核心场景模型跑通了结果怎么上屏很多做 STM32 视觉应用开发的工程师应该都有类似的经历模型选型、量化、算子替换、内存规划都逐一打通了STM32H747 上终于跑通了 UNet 或者其他图像分割模型串口也能打印出每帧的推理耗时。但是到了“给客户、给领导、给自己看效果”这一步一下子卡住了——模型输出的是 320×240 个类别编号总不能靠串口发几百行整数给人看吧这时候就需要把“图像分割模型结果可视化”这件事认真做起来。简单说它要解决的核心问题有两个第一把分割模型的输出从抽象的类别索引数组变成人眼能直接理解的彩色区域第二要在不影响整体帧率的前提下把分割彩色层叠加到原始摄像头画面上让用户可以直观看到哪些像素被识别成了哪个类别。我在多个项目里最开始采用的都是“CPU 逐像素复制 直接写显存”的方式功能可以跑通但会明显占用 CPU 时间。后续优化时我引入了 STM32H747 自带的 DMA2D 图形加速器把图像分割结果可视化的性能大幅提升。本文就把这套完整实现思路分享出来重点覆盖 DMA2D 的工作模式、类别索引到彩色掩码图层的转换、半透明叠加显示、缓存一致性处理以及常见坑点。适合正在做 STM32 边缘视觉、模型后处理或 GUI 叠加显示的同学阅读。本文不是图像分割算法的理论讲解也不会详细展开 UNet 网络训练细节而是聚焦“模型跑完之后如何把结果变成画面”这一步。2. DMA2D 与图像分割结果可视化的基础原理2.1 图像分割模型的输出到底是什么图像分割任务看似五花八门比如广告牌图像分割、医学图像分割、口腔疾病图像分割系统等等但到了模型推理阶段输出层通常都逃不过下面这几种形式。最常用的是 H×W×C 的 logits 或概率图其中 H 和 W 是模型输出分辨率C 是类别数。假设模型输入是 320×240类别数是 8那么输出可能是一个int8_t logits[8][240][320]这样的数组当然具体维度顺序取决于推理框架。如果使用 TFLite Micro、STM32Cube.AI 等部署工具量化后输出还会变成 int8 定点数。要把它变成能显示的结果需要先求 argmax也就是对每个像素位置比较 8 个类别的置信度取出最大值的索引。最终得到的是一个单通道的类别索引图例如uint8_t label_map[240][320];每个像素的值是 0 到 7分别代表背景、人、车、天空等不同类别。很多刚接触嵌入式视觉的同学在这里会犯一个错误以为模型输出的概率图就是可以显示的画面。实际上概率图通常是 8 组只有明暗灰度的数据直接显示时既看不出语义边界也无法区分类别。所以后处理的第一件事不是显示而是把类别索引映射到一组有区分度的颜色上。2.2 DMA2D 的四种核心工作模式DMA2D 是 STM32 系列中的 2D 图形 DMA 控制器在 STM32H747 上承担了图形搬运、像素格式转换和图像混合等任务。很多人把它理解成“一个能搬大块内存的 DMA”这样理解不太全面。真正让它在图像分割可视化场景中发挥作用的是下面这几种模式。第一种是内存到内存模式简称 MEM2MEM作用类似普通 DMA把一块内存原样搬到另一块内存。这种模式适合帧缓冲整体复制。第二种是带像素格式转换的内存到内存模式简称 MEM2MEM_PFC。它在搬运过程中可以自动把 ARGB8888 转成 RGB565或把 RGB888 转成 ARGB1555 等格式。如果我们要把摄像头采集的 RGB888 原始数据转成 RGB565 显存格式这种模式会比 CPU 循环转换快很多。第三种是带像素格式转换和混合的模式也是本文实现可视化叠加的核心模式。在这个模式下DMA2D 会同时读取前景层和背景层两路像素然后按配置的 alpha 值进行混合再把结果写回目标显存地址。第四种是寄存器填充模式比如 R2M可以将一块区域填充成固定颜色适合做界面背景清屏。图像分割结果可视化正好命中第三种模式前景是分割彩色掩码层背景是摄像头原始画面两者经过 alpha 混合后就能得到半透明的彩色分割叠加效果。2.3 像素格式是可视化设计的第一道选择题DMA2D 在处理图像时需要明确每一层的数据格式常见的有 ARGB8888、RGB888、RGB565 等。在 STM32H747 视觉项目里RGB565 是相当常见的选择因为摄像头采集、LCD 显示和模型输入往往都围绕 RGB565 或 RGB888 转换。RGB565 每个像素用 16 bit 表示红色占高 5 位绿色占中间 6 位蓝色占低 5 位内存占用比 ARGB8888 少一半同时能覆盖大多数显示场景。当我们需要生成彩色掩码层时建议直接使用和显示帧缓冲一致的 RGB565。这样 DMA2D 混合时就不用再做额外的输出像素格式转换。我的实际经验是尽量让摄像头输出缓冲、掩码层缓冲和 DMA2D 目标缓冲三者保持 RGB565避免在一个 320×240 画面上反复转换格式既慢又容易出错。2.4 半透明叠加的实现思路如果不用 DMA2D我们需要在 CPU 上对每个像素做如下计算输出像素 前景像素 × alpha 背景像素 × (1 - alpha)其中 alpha 是前景的不透明度。alpha 为 255 时掩码完全不透明原始画面会被完全遮盖alpha 为 0 时掩码完全透明实际可视化中通常取 100 到 180 之间的值让分割色块既能看清区域边界又不会遮挡摄像头画面细节。使用 DMA2D 混合模式后这个逐像素公式由硬件自动完成。CPU 只需要生成一张前景掩码图再指定背景图像、输出地址和 alpha 值DMA2D 会在后台完成所有像素的混合。这样 CPU 在混合期间可以继续跑下一帧的预处理或模型推理对整体视觉应用存在明显收益。3. 环境准备与工程数据流设计3.1 开发平台与工具说明本文的代码思路适用于所有带 DMA2D 外设的 STM32H7 系列芯片示例主题围绕 STM32H747 展开。STM32H747 是一颗双核芯片其中 Cortex-M7 主频较高适合运行图像预处理和神经网络推理Cortex-M4 可以用来做通信协议栈或外设管理。在图像分割可视化工程中我习惯把模型推理放在 M7 上把显示链路也放在 M7 侧M4 作为从核处理串口或网络通信。开发环境方面可以使用 STM32CubeIDE、Keil MDK 或 IAR本文不限定具体 IDE。建议使用 STM32CubeMX 完成芯片初始化、时钟树配置、LTDC 或 DSI 显示初始化、DMA2D 初始化因为手动配置 DMA2D 寄存器不仅容易出错不同 HAL 版本之间也存在 API 差异。版本方面不需要追求最新关键是开发板和固件包配套。由于不同 STM32CubeFW_H7 版本对 HAL 函数命名和内部实现存在细微差别本文给出的代码会标注为核心逻辑实际使用时请以你本地 HAL 库中的函数原型为准。3.2 视觉显示链路的硬件背景要让 DMA2D 混合后的结果显示出来还需要配合屏幕控制器。STM32H747 常见方案是通过 LTDC 驱动 RGB 接口 LCD或者通过 DSI 主机驱动 MIPI 屏。DMA2D 本身不直接连接屏幕它只负责把像素写入你指定的内存帧缓冲。LTDC 或 DSI 会在后台按帧率持续读取这段显存并发送到屏幕。因此在代码设计上建议把可视化输出目标直接指定为“当前 LTDC 正在显示的帧缓冲”或者“备用帧缓冲”。如果采用双缓冲机制需要确保 DMA2D 正在写入的缓冲没有被 LTDC 同时读取否则画面会出现撕裂。实际项目中我通常这样安排摄像头采集缓冲 - 模型预处理 - 模型推理 - label_map ↓ 彩色掩码层生成 ↓ DMA2D 混合(掩码层 原图) - 显存帧缓冲 - LTDC 刷新在这种数据流中掩码层生成和 DMA2D 混合之间是明显的前后依赖关系但 DMA2D 混合期间CPU 可以提前去做下一帧的图像缩放或归一化这是整个方案能够提升帧率的关键。3.3 显存与缓冲规划在 STM32H7 系列上DMA2D 和 LTDC 都工作在 AXI 总线上常用外部 SDRAM 作为显存。320×240 的 RGB565 图像单帧仅占 153600 字节也就是 150 KB 左右。如果使用 480×272 分辨率的屏幕单帧 RGB565 约 261 KB。所以我们至少需要规划以下几块缓冲摄像头原始画面帧缓冲RGB565 格式模型输出类别索引数组例如 320×240 字节彩色掩码层帧缓冲RGB565 格式DMA2D 输出目标帧缓冲也就是实际显示到屏幕的 RGB565 帧缓冲。如果你的显示分辨率高于模型输出分辨率比如模型输出是 320×240屏幕是 480×272那么彩色掩码层在显示时可能还需要缩放或居中拷贝。需要注意 DMA2D 在混合模式下虽然能做行偏移裁剪但并不提供插值缩放能力。最稳妥的做法是让模型输出尺寸与显示窗口尺寸一致或者在生成彩色掩码层时将 label_map 缩放后填入掩码缓冲。后一种方法在前面的 CPU 循环中同时实现不会增加 DMA2D 负担。4. 核心代码实现从类别索引到彩色叠加画面4.1 定义全局参数和缓冲区为了便于阅读我在工程里单独建立了一个mask_overlay.h文件把屏幕尺寸、模型尺寸、类别数量和缓冲声明集中管理。/* 文件路径app/mask_overlay.h */ #ifndef __MASK_OVERLAY_H #define __MASK_OVERLAY_H #include stdint.h #define DISPLAY_WIDTH 320 #define DISPLAY_HEIGHT 240 #define MODEL_WIDTH 320 #define MODEL_HEIGHT 240 #define MODEL_PIXEL_NUM (MODEL_WIDTH * MODEL_HEIGHT) #define NUM_CLASSES 8 extern uint8_t s_model_label_buf[MODEL_PIXEL_NUM]; extern uint16_t s_camera_frame_buf[MODEL_PIXEL_NUM]; extern uint16_t s_mask_color_buf[MODEL_PIXEL_NUM]; extern uint16_t s_display_frame_buf[MODEL_PIXEL_NUM]; void MaskOverlay_LabelToColor(void); void MaskOverlay_BlendWithDma2d(uint8_t alpha); #endif这里的s_model_label_buf存放模型 argmax 之后的类别索引s_camera_frame_buf存放摄像头原始画面s_mask_color_buf是待生成的彩色掩码层s_display_frame_buf是 DMA2D 混合后的最终显示缓冲。4.2 类别索引到 RGB565 彩色掩码转换图像分割结果可视化的第一步是把每个像素的类别索引映射为具体的 RGB565 颜色值。我建议使用查找表方式而不是每个类别写一个 if-else。类别数量通常不会太多8 类或 20 类以内用数组查表最简单可读性和可维护性也更高。/* 文件路径app/mask_overlay.c */ #include mask_overlay.h #include dma2d.h /* * RGB565 颜色查找表索引对应类别 ID。 * 实际项目里请根据分割目标自由调整比如 * 类别0 背景颜色使用黑色 * 类别1 人颜色使用红色 * 类别2 车颜色使用绿色 * 类别3 道路颜色使用蓝色等。 */ static const uint16_t s_class_color_lut_rgb565[NUM_CLASSES] { 0x0000, /* 0 背景黑色 */ 0xF800, /* 1 红色 */ 0x07E0, /* 2 绿色 */ 0x001F, /* 3 蓝色 */ 0xFFE0, /* 4 黄色 */ 0xF81F, /* 5 品红 */ 0x07FF, /* 6 青色 */ 0xFFFF, /* 7 白色 */ }; void MaskOverlay_LabelToColor(void) { uint32_t i; for (i 0; i MODEL_PIXEL_NUM; i) { uint8_t label s_model_label_buf[i]; if (label NUM_CLASSES) { label 0; } s_mask_color_buf[i] s_class_color_lut_rgb565[label]; } }这一步骤发生在 CPU 上本质上是一个线性查表循环。对 320×240 分辨率来说一共 76800 个像素。Cortex-M7 在开启最高优化等级后这个循环通常只需要零点几毫秒到一毫秒左右如果使用未优化编译耗时可能明显增加。这里需要说明具体耗时和编译器、优化选项、内存位置都有关系如果你需要在特定板卡上做精确评估可以通过 DWT 周期计数器或 HAL 定时器统计。4.3 DMAD2 混合前的层配置与启动当彩色掩码层生成完毕后下一步就是使用 DMA2D 的混合模式把s_camera_frame_buf和s_mask_color_buf叠加成最终画面。我建议基于 CubeMX 生成的hdma2d句柄来配置不要自己重新初始化一个新的局部句柄否则容易造成外设状态丢失。/* 文件路径app/mask_overlay.c */ /* * 使用 DMA2D 将彩色掩码层作为前景原始图像作为背景 * 按 alpha 合成到 s_display_frame_buf。 * alpha 取值范围 0-255255 表示完全不透明。 */ void MaskOverlay_BlendWithDma2d(uint8_t alpha) { extern DMA2D_HandleTypeDef hdma2d; /* * 如果程序在启动阶段已经调用过 HAL_DMA2D_Init * 并且初始化时 Mode 设置为 DMA2D_M2M_BLEND * 这里只需要重新配置前景与背景层的输入格式和 alpha。 */ hdma2d.LayerCfg[0].InputColorMode DMA2D_INPUT_RGB565; hdma2d.LayerCfg[0].InputOffset 0; hdma2d.LayerCfg[0].AlphaMode DMA2D_REPLACE_ALPHA; hdma2d.LayerCfg[0].InputAlpha alpha; HAL_DMA2D_ConfigLayer(hdma2d, 0); hdma2d.LayerCfg[1].InputColorMode DMA2D_INPUT_RGB565; hdma2d.LayerCfg[1].InputOffset 0; hdma2d.LayerCfg[1].AlphaMode DMA2D_NO_MODIF_ALPHA; hdma2d.LayerCfg[1].InputAlpha 0xFF; HAL_DMA2D_ConfigLayer(hdma2d, 1); HAL_DMA2D_BlendingStart(hdma2d, (uint32_t)s_mask_color_buf, (uint32_t)s_camera_frame_buf, (uint32_t)s_display_frame_buf, DISPLAY_WIDTH, DISPLAY_HEIGHT); HAL_DMA2D_PollForTransfer(hdma2d, 100); }这里需要注意不同 HAL 版本的AlphaMode枚举名可能不完全一样例如有的版本写DMA2D_REPLACE_ALPHA有的版本语义是替换输入像素 alpha。因此这段代码的定位是“核心逻辑参考”不是一定能在任意 HAL 版本上直接编译通过。如果你在使用过程中发现AlphaMode参数和预期不一致建议先查阅 STM32CubeFW_H7 包里的stm32h7xx_hal_dma2d.h头文件确认相关枚举名。4.4 主循环中的调用顺序在主函数中可视化的调用逻辑可以拆成四步获取摄像头帧、执行模型推理、调用彩色掩码生成函数、调用 DMA2D 混合函数。/* 文件路径app/mask_overlay.c */ void MaskOverlay_Run(void) { /* 1. 摄像头已完成 DMA 采集原始画面存放在 s_camera_frame_buf */ /* 2. 模型推理完成结果存放在 s_model_label_buf */ /* 3. 将类别索引数组转成 RGB565 彩色掩码层 */ MaskOverlay_LabelToColor(); /* 4. DMA2D 混合alpha 取 150 左右适合大部分场景 */ MaskOverlay_BlendWithDma2d(150); }如果显示控制器 LTDC 当前正在显示s_display_frame_buf那么这帧画面就会立刻被更新。为了优化显示效果建议在 LTDC 的垂直消隐期启动 DMA2D 混合避免画面断裂。实际工程中可以在 LTDC 中断回调里置一个标志位主循环检测到标志位后再调用MaskOverlay_BlendWithDma2d。4.5 双缓冲与显示刷新如果项目对帧率和画面连贯性要求更高建议引入双缓冲机制。具体做法是准备两个显示帧缓冲一个给 LTDC 当前扫描显示另一个留给 DMA2D 写入。DMA2D 写入完毕后通过切换 LTDC 当前层地址来让新画面生效。/* 文件路径app/mask_overlay.h */ extern uint16_t s_display_frame_buf_a[MODEL_PIXEL_NUM]; extern uint16_t s_display_frame_buf_b[MODEL_PIXEL_NUM];切换 LTDC 层地址时需要进入临界区或者等待当前帧扫描结束避免 LTDC 在扫描到一半时切换地址导致画面出现上下撕裂。STM32H7 HAL 里可以通过修改 LTDC 层结构体并调用HAL_LTDC_SetAddress完成切换也可以直接操作寄存器层地址字段。无论使用哪种方式都要遵守一个原则不要让 LTDC 正在读取的显存区域被 DMA2D 同时写入。5. 性能视角CPU 查表循环和 DMA2D 混合如何分工5.1 耗时的大致量级估算图像分割模型结果可视化的耗时主要由两部分组成一是 CPU 生成彩色掩码层的查表循环二是 DMA2D 硬件混合。对于 320×240 分辨率CPU 查表循环处理 76800 个像素。在 Cortex-M7 上每个像素的操作包括一次数组读取、一次边界判断、一次查表、一次写入。我实际在开启-O2后统计耗时量级约在 0.5ms 到 1ms 区间如果使用低优化等级或从外部 Flash 读取数据耗时可能会增加。这只是一个量级参考不建议直接当作绝对性能指标最好结合你的编译器和实际运行频率测试。DMA2D 混合的耗时由 DMA2D 时钟频率、总线带宽和像素格式决定。RGB565 格式下DMA2D 需要同时读取前景和背景两个 16 位像素计算混合结果后再写回目标地址。320×240 画面数据量不大DMA2D 完成一次混合通常远小于 CPU 逐像素 for 循环的耗时。更重要的是DMA2D 启动后不需要 CPU 干预CPU 可以把这段时间让给下一帧预处理任务。5.2 什么情况下 DMA2D 优化不明显如果你的模型分辨率非常小比如只有 96×96那么 CPU 逐像素循环本身也很快此时使用 DMA2D 带来的性能收益就不如高分辨率场景明显。但即使耗时节省不多DMA2D 仍然有一个重要价值它让“图像叠加”这件事从业务代码中解耦了。我们只需要维护好前景层、背景层和输出地址中间不会因为摄像头格式变化、显示区域变化而频繁修改逐像素混合算法。5.3 不要让模型输出直接作为 DMA2D 前景层有些开发者会思考既然 DMA2D 支持 PFC能否把模型输出的类别索引数组直接当成 DMA2D 输入让硬件做色彩映射这个想法很好但 DMA2D 的 PFC 只是做像素格式转换不是通用查表。例如它无法根据“像素值为 3”自动扩展成 RGB565 颜色。因此类别索引到颜色的映射仍需 CPU 完成。一个好的优化思路是使用 ARGB8888 格式透明层因为 ARGB8888 每个像素自带 alpha 通道DMA2D 混合时可以直接使用像素内 alpha但 RGB565 没有 alpha 通道所以必须额外设置固定 alpha。如果项目使用的是量化模型输出的 label_map 清理后通常只包含有限个类别编号比如 0 到 7。那么在边界判断时可以把 uint8 强制当成数组索引只要确保模型输出不会越界即可。这样能省掉一次次判断获得更好的循环性能。6. 常见问题与排查思路6.1 图像花屏、随机噪点很多这个问题的根本原因通常是 D-Cache 一致性问题。STM32H747 的 CPU 核心带有 D-Cache当我们用 CPU 写好了s_mask_color_buf数据可能还留在 Cache 中并没有真正写回物理内存。DMA2D 作为外设通常不经过 CPU Cache它直接读取物理内存这个时候读到的可能是旧数据或者不完整数据表现出来就是花屏、噪点或者叠加层残缺。解决问题的标准流程是在 DMA2D 启动之前对 CPU 写入的缓冲区域执行 Cache Clean确保脏数据回写对 DMA2D 写入的目标区域在 CPU 后续读取或继续处理前执行 Cache Invalidate。/* 文件路径app/mask_overlay.c */ #include core_cm7.h void MaskOverlay_CleanAndInvalidateCache(void) { SCB_CleanDCache_by_Addr((uint32_t *)s_model_label_buf, sizeof(s_model_label_buf)); SCB_CleanDCache_by_Addr((uint32_t *)s_mask_color_buf, sizeof(s_mask_color_buf)); SCB_CleanDCache_by_Addr((uint32_t *)s_camera_frame_buf, sizeof(s_camera_frame_buf)); SCB_InvalidateDCache_by_Addr((uint32_t *)s_display_frame_buf, sizeof(s_display_frame_buf)); __DSB(); }调用顺序是先 clean 输入侧缓冲再 invalidate 输出侧缓冲最后加一条__DSB()保证内存屏障生效。如果不处理 Cache 一致性问题即使 DMA2D 配置完全正确显示效果仍然会是随机的。6.2 叠加画面完全不透明背景被完全挡住从现象看说明混合没有真正生效或者 alpha 被配置成了 255。排查方向如下表问题现象常见原因解决思路背景完全消失前景 alpha 设置为 255将 alpha 降低到 120-180前景层显示不出来DMA2D 模式不是混合模式检查 Init.Mode 是否设置为 DMA2D_M2M_BLEND只有背景没有彩色层掩码缓冲未成功生成检查 label_map 是否全 0或颜色查找表是否全黑输出区域错位行偏移 InputOffset 配置错误确认层的宽度与输入缓冲 stride 一致6.3 颜色偏色或红蓝通道对调这种现象通常出现在摄像头原始缓冲和 DMA2D 前景层的像素格式不一致时。比如前景层实际是 RGB565但 DMA2D 被配置成了 BGR565或者摄像头输出本身是 RGB888但在缓冲区中没有完成格式转换。可以在调试时先关闭混合单独把摄像头原图拷贝到目标显存观察画面颜色是否正确再单独把彩色掩码层填充成纯红色检查 DMA2D 颜色输入是否正常。通过这种逐层排除法能很快定位是哪一侧的格式配置出了问题。6.4 画面出现撕裂或局部闪烁撕裂问题通常与显存访问竞争有关。LTDC 正在从s_display_frame_buf读取扫描数据时如果 DMA2D 同时向这同一块缓冲写入就会导致屏幕上半部分还是旧帧、下半部分已经是新帧。解决办法是使用双缓冲或者将 DMA2D 的启动时机放在 LTDC 垂直消隐期。如果你的屏幕分辨率不高、帧率要求也不高最简单的方式是在主循环等待 LTDC 的垂直消隐标志位后再启动 DMA2D 混合。6.5 DMA2D 中断和轮询超时有些工程会使用HAL_DMA2D_PollForTransfer等待传输完成但传入的超时时间太短导致频繁返回超时错误。另一种情况是 DMA2D 中断已经使能但中断回调中访问了错误的数据缓冲区地址。调试建议是先在 CubeMX 中关闭 DMA2D 全局中断只使用轮询方式验证功能功能稳定后再切换到中断或 DMA 方式避免一开始就陷入中断嵌套和缓冲管理问题。7. 工程化最佳实践让可视化模块稳定可维护7.1 把可视化代码独立封装成模块不要把 label 映射、DMA2D 混合、缓存刷新的代码全部散落在主循环里。建议单独建立mask_overlay.c和mask_overlay.h对外只暴露一个类似MaskOverlay_Run()的接口。这样当模型从 UNet 换成其他分割模型或者类别数量从 8 类变成 20 类时只需要修改颜色查找表和类别总数不需要动主流程代码。7.2 使用 RGB565 颜色查找表而不是运行时计算RGB565 的颜色计算规则可以提前在 PC 端生成也可以手工写常量。如果类别很多建议用 Python 脚本生成s_class_color_lut_rgb565数组再把生成的 C 代码拷贝到工程里。这样能避免在 MCU 上执行 HSV 到 RGB 的转换省去不必要的 CPU 开销。7.3 在 alpha 与可读性之间做取舍alpha 值并不是越大越好。分割色块太透明用户看不清边界太不透明又会遮挡原始画面。我通常在 140 到 180 之间选择。如果希望对某些危险目标所在区域做重点提示可以将该类别的颜色直接设置为不透明或者对该类别额外渲染一个边框而不是把所有类别都设成高不透明度。7.4 给可视化模块增加在线开关实际项目中用户可能希望随时切换“纯摄像头画面”和“分割叠加画面”。最简单的方式是通过一个标志位控制是否执行MaskOverlay_BlendWithDma2d不执行时直接把摄像头原图拷贝到显示缓冲。这样能方便对比模型效果也方便在调试阶段快速定位问题是来自模型还是来自可视化。7.5 关注模型