☰
FPGA运动目标检测实战:帧差法Verilog实现与DDR3缓存设计
2026/10/15 7:36:53 网站建设 项目流程

最近把一个FPGA运动目标检测的仿真工程完整跑通了,硬件用的是手头某原子达芬奇系列开发板,软件在Vivado和Quartus两个环境里都验证过。这套代码实现的是基于帧差法的运动目标检测,从摄像头采集到HDMI显示一条链路全部打通,仿真测试也一并做完了。今天把整个实现思路、模块拆解、关键代码和踩过的坑一次性写清楚。如果你正在做类似的图像处理项目,或者手里正好有达芬奇、新起点系列板子想跑图像算法,这篇内容可以直接拿来参考。

先说清楚这套东西能干什么:摄像头对着一个场景,当画面里有物体移动时,系统会把运动区域实时框出来,在显示器上叠加显示。处理帧率受限于摄像头和DDR3带宽,实测在720P分辨率下能跑到60fps左右,1080P下也能稳定30fps。整个工程代码量大概两千行左右,核心模块包括灰度转换、帧差计算、阈值分割、形态学滤波和目标位置标记。

FPGA做运动目标检测,和用OpenCV在电脑上跑完全是两种思路。电脑上资源充裕,可以直接调库、用浮点、上深度学习模型;FPGA上所有逻辑都要用硬件资源去搭,需要考虑流水线、位宽、资源占用和时序收敛。但FPGA的优势是低延迟、高吞吐、并行处理,适合实时性要求很高的场景。这篇文章会把每个关键决策背后的原因讲明白,不是光给代码,而是让你知道为什么这么做。

1. 项目拆解:这套FPGA运动目标检测到底在做什么

1.1 为什么选帧差法而不是光流和背景建模

运动目标检测的经典方法有三种:帧差法、背景减除法、光流法。我最初也纠结过选哪种,最后综合FPGA资源限制和实时性要求,选了帧差法。

帧差法原理很直接:连续两帧图像做差分,像素值变化超过阈值的地方,就认为是运动区域。公式就是|I(n) - I(n-1)| > T,I(n)表示当前帧灰度值,T是阈值。这个操作在FPGA上实现非常友好,只需要两个数据源做减法、取绝对值、比较阈值,三个周期就能出结果,适合流水线处理。

背景减除法需要在内存里维护一张背景模型,并且要不断更新背景以适应光照变化。这个模型在FPGA里实现会比较占资源,尤其是自适应更新逻辑,往往需要一个额外的帧缓存和复杂的算术运算。光流法精度最高,能算出每个像素的运动矢量,但涉及矩阵运算和迭代求解,硬件实现难度大,通常在GPU或者高端DSP上才能满足实时性。对于入门级FPGA开发板,从可行性角度讲,帧差法是性价比最高的方案。

帧差法的缺点也得说清楚:它只能检测运动物体的边缘轮廓,如果物体颜色和背景接近或者运动速度太慢,检测效果会打折扣。最典型的问题是“空洞效应”,就是运动物体内部颜色均匀的区域,帧差后像素变化值很小,会出现中间空洞、只有边缘被检测出来的情况。后文会讲怎么用形态学滤波尽量缓解这个问题。

1.2 开发板与开发工具的匹配思路

这套工程考虑了不同开发板的兼容性。达芬奇系列和某原子新起点系列在FPGA资源上有差异,前者资源相对充裕,后者更入门一些,所以在代码设计上做了一个核心原则:尽量使用通用原语和标准接口,避免依赖某一款板子的专属硬件。

具体来说,工程分成两个版本:一个面向Vivado,主要使用AXI4接口和Xilinx的Video In/Out IP;另一个面向Quartus,改为用Altera的Streaming Video IP和PLL。两份代码的核心算法模块(灰度、帧差、形态学)完全一致,只是接口适配层不同。这样做的好处很明显——算法逻辑是平台无关的,换板子只需要改接口适配层,核心识别逻辑不用动。

开发工具版本方面,Vivado用的是2018.3以上的版本,Quartus Prime 18.1也测过。有一点值得注意:Quartus对Verilog-2001的支持很完整,但对SystemVerilog的部分语法支持不够好,所以在公共模块里统一用Verilog-2001风格编写,不用SV的interface和logic类型,这样在两个工具里都能直接综合。

2. 系统方案设计:从镜头到屏幕的数据流该怎么走

2.1 整体架构与数据通路

整套系统的数据流向是这样的:CMOS摄像头输出RAW Bayer格式数据,经过摄像头配置和采集模块送到灰度转换模块,转成8bit灰度图。灰度数据兵分两路,一路直接送进帧差模块作为当前帧输入,另一路经过帧缓存模块(存在DDR3里)延迟一帧时间后,作为上一帧数据也送到帧差模块。帧差结果经过阈值分割变成二值图,再做形态学滤波去噪,最后通过目标定位模块计算运动区域的包围框,叠加到原画面上通过HDMI输出。

这个流程里最核心的设计点是“如何获得上一帧数据”。很多人第一反应是用一块SRAM存一整帧,但720P一帧灰度数据就是921600字节,一片普通的SRAM通常只有512KB甚至更小,根本装不下。所以正确做法是外挂DDR3,通过AXI4接口做帧缓存。这里用的是“双缓冲”思路:DDR3里划分两个帧缓冲区,当前帧写入Buffer A的同时,从Buffer B读出上一帧,下一帧写入Buffer B的同时从Buffer A读出。双缓冲可以避免读写同一块内存时的冲突,但前提是DDR3带宽要够。

2.2 模块划分、位宽与资源预算

整个工程按功能拆分成7个模块,每个模块职责单一:

  • cmos_capture:负责CMOS传感器初始化,通过I2C配置寄存器,接收PCLK像素时钟和同步信号,输出24bit RGB数据。
  • rgb2gray:将RGB888转成8bit灰度。
  • frame_buffer:DDR3读写控制封装,对外提供写入端口和读取端口,内部处理AXI4协议和地址管理。
  • frame_diff:当前帧和上一帧做差分、取绝对值、阈值比较。
  • morph_filter:3x3中值滤波和腐蚀膨胀操作。
  • target_loc:计算运动区域的包围框坐标,通过简单的行列扫描和连续区域合并实现。
  • timing_gen:生成HDMI需要的行场同步时序和像素时钟。

位宽设计要提前想清楚,不然最后改起来很痛苦。灰度统一用8bit,差分结果理论上范围是0到255,也占8bit。二值化输出是1bit,但在形态学滤波中,为了利用FPGA的并行比较能力,我把二值图扩展成8bit的0/255表示,避免数据位宽频繁转换带来的麻烦。坐标寄存器用16bit,1080P的宽度1920绰绰有余。

资源预算方面,达芬奇系列跑这套工程,LUT占用约12000,FF约15000,BRAM约60块,DSP几乎不用(灰度转换用移位实现,没有用乘法器)。新起点系列资源少一些,也能跑,但需要把显示分辨率降到720P,并适当减少形态学滤波的窗口大小。一般来说,入门级FPGA跑720P的帧差检测是没问题的,1080P就建议用资源更充足的板子。

3. 仿真代码实现:核心模块的Verilog写法拆解

3.1 灰度转换:用移位运算替代浮点乘法

RGB转灰度的标准公式是Y = 0.299R + 0.587G + 0.114B。这个公式里有浮点系数,在FPGA里直接做浮点运算是很浪费的。常用做法是把系数放大成整数再右移,比如乘以256后再右移8位,系数变成76、150、29(取整后)。但更高效的做法是只用移位和加法,连乘法器都不用,因为FPGA里乘法和加法都会消耗逻辑资源,而移位操作几乎不占资源。

我用的近似公式是:

Y = (R + (R << 1) + (R << 3) + (G << 2) + (G << 3) + (G << 4) + (B << 1) + (B << 2)) >> 4

拆开看就是:灰度近似等于(11R + 56G + 14B) / 16,虽然和标准系数有误差,但视觉上几乎没有区别,而综合后节省了三个DSP乘法器。这个技巧在FPGA图像处理里非常常用,凡是遇到固定系数的浮点运算,都可以用“放大成整数+移位”的方式硬算。

有一点要特别提醒:移位写法看着简单,但一定要加足够的中间位宽。R、G、B都是8bit,左移4位后最大是12bit,如果中间寄存器只定义8bit,高位直接被截掉,灰度图会变得一团糟。这块我当时调试时踩过坑,波形仿真中看到的输出一直是乱的,后来发现是中间变量位宽不够。所有中间加法结果建议至少定义一个REGBITS = BW + 4的位宽,最后再右移截断到8bit。

3.2 帧差计算与阈值分割的关键细节

帧差模块是整个工程的核心,代码本身不长,但有几个关键信号要处理对。先看简化版代码:

module frame_diff #( parameter DW = 8, parameter THRESHOLD = 32 )( input wire clk, input wire rst_n, input wire [DW-1:0] gray_cur, input wire [DW-1:0] gray_prev, input wire data_valid, output reg [DW-1:0] diff_out, output reg diff_valid ); reg [DW:0] diff_abs; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin diff_out <= 8'd0; diff_valid <= 1'b0; end else if (data_valid) begin if (gray_cur >= gray_prev) diff_abs <= {1'b0, gray_cur} - {1'b0, gray_prev}; else diff_abs <= {1'b0, gray_prev} - {1'b0, gray_cur}; diff_out <= (diff_abs > THRESHOLD) ? 8'd255 : 8'd0; diff_valid <= 1'b1; end else begin diff_valid <= 1'b0; diff_out <= 8'd0; end end endmodule

两个细节值得讲。第一个是减法运算必须扩展位宽。两个8bit数相减,结果范围是-255到255,如果直接定义8bit寄存器做减法,会出现溢出和补码问题。这里我把两个操作数都扩展成9bit再做差,确保不会溢出。第二个是data_valid信号必须参与控制。摄像头输出的像素是在有效数据窗口内才有效的,行消隐和场消隐期间PCLK依然在跳变,但像素数据是无效的。如果不对valid做判断,帧差模块会把消隐期的垃圾数据处理一遍,出来的画面会出现一道道横条纹。

阈值THRESHOLD选多少是个经验问题。我最初设成16,发现室内灯光稍微有点变化,整个画面都在闪。后来提高到40,又发现运动的暗色物体检测不到了。经过多轮测试,8bit灰度下阈值设在25到35之间比较稳。这个值不能做成固定常量,最好通过串口或者按键实时调整,方便在不同环境光照下用。我在工程里也加了这样的按键调节逻辑,上板调试时按几个键就能改变阈值,比重新编译快得多。

3.3 形态学滤波与目标定位的处理逻辑

帧差二值化后的图像有很多噪声点,主要来自传感器暗电流和光照波动。直接用这种带噪点的二值图去框目标,会出现大量误检小框。所以必须做形态学滤波。我实现的是3x3窗口的“中值滤波+膨胀”组合。

在FPGA里做3x3窗口滤波,绕不开行缓存(Line Buffer)。3x3窗口意味着同时要访问三行数据,最简单的方式是用两个移位寄存器(SHIFT_REG原语),分别延迟1行和2行,这样在任意时刻可以得到同一列上三行的数据。这是Vivado和Quartus都支持的硬件原语,不是可综合的普通数组,用起来要留意。

// 例化两个行延迟模块 shift_reg #( .DW(8), .DEPTH(H_ACTIVE) ) line_delay_1 ( .clk(clk), .din(gray_in), .dout(line1_data) ); shift_reg #( .DW(8), .DEPTH(H_ACTIVE) ) line_delay_2 ( .clk(clk), .din(line1_data), .dout(line2_data) );

中值滤波对3x3窗口的9个像素排序取中间值,在FPGA里可以用排序网络实现:先对三行分别排序,再对排序结果的列排序,最后取对角中间值。这种方法用比较器比较多,但延迟只有3个周期。其实实战里还有个更省资源的做法:直接用“最小值和最大值之间取中间”的近似,效果略差但只用少数比较器。我因为FPGA资源够用,用了完整排序网络,如果你用的是资源紧张的板子,可以降级成近似方案。

目标定位模块的思路是逐行扫描二值图,遇到255的像素就记录当前行列,然后维护一个滑动窗口:同一目标内的像素坐标范围会不断扩张,直到连续N行都没有新像素加入,就认为一个目标扫描结束,输出包围框坐标。这个算法类似简单的连通域标记,但在硬件里实现的是“单目标跟踪”和“框坐标更新”,逻辑不算复杂,缺点是不能同时跟踪多个分离目标。如果需要多目标跟踪,就得引入帧间多目标匹配,工作量和复杂度会明显上升,属于另一个量级的项目了。

4. 工程搭建、DDR3调度与综合时序实战

4.1 Vivado与Quartus的工程创建差异

同一套算法代码在两个工具里搭建工程,流程上大同小异,但有几个区别需要提前知道。

Vivado这边,我建议用Block Design的方式搭视频通路。原因很简单:Xilinx提供了完整的视频处理IP链,包括Video In to AXI4-Stream、VDMA、AXI4-Stream to Video Out,这些IP之间用AXI4-Stream协议连接,配合AXI4互联实现DDR3读写,比自己手写AXI接口省太多时间。算法模块作为自定义IP插入到AXI4-Stream数据流中,用Vivado的IP Integrator可以快速完成连线。关键是把时钟和复位逻辑理顺,视频处理链路一般跑像素时钟,VDMA和DDR3跑200MHz左右的高频时钟,两个时钟域之间用异步FIFO衔接。

Quartus这边则不同。虽然Altera也有类似的MegaWizard IP,但IP种类和接口风格有差异。我在Quartus工程里没有用整套视频IP链,而是自己写了简单的FIFO和DDR3控制器封装。这听起来工作量很大,实际上Quartus的DDR3 UniPHY IP会帮你生成完整的DDR3控制器,对外提供简单的Avalon-MM接口,配合FIFO模块就能实现帧缓存。相比Vivado的VDMA,这种方式更底层、更可控,适合理解原理,但开发周期确实更长。

4.2 DDR3缓存架构与读写带宽测算

帧缓存是整个系统的“血管”,DDR3调度出问题,画面就会花屏、撕裂、卡顿。先算一笔带宽账,你就知道为什么有的板子跑不动1080P。

按720P@60fps算,每帧有效像素是1280x720=921600,灰度数据8bit,也就是约0.9MB。一秒钟60帧,数据量约55MB。写入DDR3需要55MB/s带宽,读出一帧给帧差模块也需要55MB/s,加上显示输出从DDR3读回又是55MB/s,总共约165MB/s。这还不算DDR3刷新开销和AXI协议头开销,实际有效带宽需求在250MB/s左右。DDR3的带宽通常有几GB/s,看起来绰绰有余对吧?但问题是DDR3访问有延迟,如果读写请求没有充分批处理,每次随机访问的带宽利用率可能只有百分之十几。

所以DDR3读写调度的核心原则是:把二维图像访问转成一维长突发访问。每一行图像数据在DDR3里连续存放,读写的时候尽可能一次发满一串长度的突发请求,比如一行的1280字节拆成20次64字节的突发写。避免每次只写几个像素就换行,那样会产生大量地址切换和bank切换开销,带宽利用率会崩掉。

还有一个工程上的取巧点:VDMA本身自带内部缓存和地址生成逻辑,用它的Frame Write和Frame Read通道,可以天然地把整帧图像按行突发搬运。我用VDMA的S2MM通道写DDR3,用MM2S通道读DDR3,两个通道各自独立,由VDMA内部状态机调度,省去了大量手动管理地址的麻烦。如果你用Quartus,就手动实现一套类似的按行突发逻辑,逻辑复杂度会高一些,但原理一致。

4.3 综合时序违例的处理方法

这套工程在Vivado里综合后,第一次跑时序是有一堆违例的,主要集中在两个地方:一是像素时钟域内的组合逻辑链太长,二是跨时钟域FIFO的同步逻辑。

灰度转换模块的问题最典型。Y = (R + (R<<1) + (R<<3) + ...) >> 4这个表达式,如果不做任何处理直接综合,编译器会把所有加法串成一长条组合逻辑链,从输入到输出的延迟可能是10ns以上,而1080P@60Hz的像素时钟约148.5MHz,周期只有6.7ns,时序必然违例。

解决办法是在加法链中间插入寄存器打拍。把大表达式拆成两级:先算R和G的中间和,再算B和上一步结果的和,每级之后都加寄存器。这样组合逻辑长度直接减半。Vivado综合时建议开启-retiming选项,会自动做流水线重定时。Quartus也有类似的优化选项。

处理完加法链流水线之后,又遇到一个问题:在模块握手信号上,数据路径没有延迟对齐。数据和valid信号之间差了一个周期,导致下游模块可能采到无效数据。这个问题最容易出现在自己写的模块拼接时。方法很土但很有效:写一个简单的testbench,把模块A的输出直接接到模块B的输入,对比每个时钟周期两个模块的data_valid和数据是否对齐。仿真过了,时序大概率没问题;仿真对不上,上板必挂。

5. 调试实录:仿真过不代表上板就稳

5.1 仿真的局限与激励构造

FPGA开发的常规流程是先仿真后上板,但很多新手在仿真阶段就把错误路径走歪了。必须明确一点:仿真验证的是逻辑行为,不是时序。仿真里看不出DDR3带宽瓶颈,看不出组合逻辑延迟,更看不出信号完整性问题。仿真过了只能说明功能正确,离“能上板跑”还有不小距离。

仿真激励的构造上,最省事的办法是用文本文件读取像素数据。testbench里用$readmemh读取一张转成txt的灰度图,模拟连续两帧图像输入。或者直接在代码里用task生成简单的图像数据,比如一帧全黑、一帧带一个白色方块,这样帧差结果一目了然。我习惯两种都做:先用合成图验证帧差逻辑对不对,再用真实图片序列验证算法效果。

这里有个常见误区:仿真用的图像尺寸和实际分辨率不一致。我见过有人用4x4的测试图仿真,逻辑都对,一上板接720P摄像头就乱了。原因是很多模块内部的行计数器、帧同步逻辑是依赖具体分辨率的,分辨率变了,时序自然乱。建议仿真时用至少64x64的图像,并且把实际分辨率H_ACTIVE、V_ACTIVE作为参数传入,而不是在模块里写死。这样换分辨率只改参数,不用改代码。

5.2 上板调试的经典故障

上板调试我遇到的第一个问题就是花屏。现象是HDMI输出画面整体错位,上半部分是绿色噪点,下半部分是正常的图像。排查到最后发现是VDMA的帧地址设置问题。VDMA配置成自动地址模式后,Frame Buffer地址需要按当前帧率和分辨率计算对齐,地址没有按4096字节边界对齐,导致读出的数据行错位。解决办法是在DDR3地址分配时,给每个帧缓冲区的起始地址做一个对齐宏,保证首地址是4096的整数倍。

第二个问题是帧差图像闪烁。现象是画面静止时依然有大量白色噪点持续闪动。一开始怀疑是帧差阈值太低,调高后确实改善了,但没有完全消除。后来发现真正的元凶是DDR3读回数据和写入数据的时钟不同步,导致读回的“上一帧”其实混入了当前帧的部分内容。帧差算出的自然有差异。这个问题的根源是跨时钟域处理不彻底。我重新检查了VDMA的读通道和写通道的异步FIFO,确认FIFO复位时序正确、且读写的像素对齐后,闪烁基本消失。

第三个问题比较隐蔽——目标定位框抖动。目标明明没有移动,框的位置却在左右晃动。这是因为静止背景下也有一些细小的帧差像素残留在边缘,逐行扫描时,这些残差像素会被当成目标的一部分,导致包围框宽度不稳定。解决办法是在目标定位模块里加了“最小框尺寸过滤”,框宽或者框高小于设定值的直接丢弃。加上这个过滤之后,框的稳定性明显好多了。

5.3 常见问题速查表

把这段时间踩过的坑整理成一张表,方便大家直接对照排查:

问题现象可能原因排查方法 / 解决方案
输出画面花屏、行错位DDR3帧地址未对齐确认Frame Buffer地址按4096字节对齐,核对VDMA地址寄存器
静止画面帧差闪烁读回帧数据不完整/不同步检查异步FIFO复位,确认读通道像素与写通道严格对齐
目标框抖动不稳定帧差残渣噪声并入目标区域在定位模块中增加最小框宽高过滤,或增大形态学膨胀次数
帧差结果空洞明显运动物体内部颜色均匀适当增大膨胀操作次数,让空洞被填充
灰度图整体发绿/发紫RGB顺序或位宽分配错误用纯色测试图验证R/G/B通道输入顺序
视频流畅但检测延迟大帧差缓存用了多帧延迟确认帧缓存模块只做一帧延迟,检查VDMA控制器的Frame Count设置
阈值调低就全屏噪点环境光照波动大改用自适应阈值或增加形态学滤波强度
Quartus综合资源不足行缓存用了大量分布式RAM改用Block RAM实现Shift Register,减少窗口大小

这里面特别要说一下“帧差结果空洞”的解决方式。膨胀操作确实可以把空洞填上,但膨胀同时会让运动区域的边缘向外扩张,如果膨胀次数多了,两个靠得近的目标可能会连成一片。所以膨胀次数建议控制在1到2次,不要贪多。更精细的做法是用“先闭运算再开运算”,即先膨胀后腐蚀,再腐蚀后膨胀,能同时保留目标轮廓并去除孤立噪声点。代价是逻辑资源和处理延迟翻倍,看你的实时性需求决定要不要上。

还有一个细节值得说:帧差检测对摄像头固定程度非常敏感。如果你用的不是工业级固定支架,而是手持或者带有自动对焦的摄像头,即使没有任何运动物体,对焦过程本身就会引起画面变化,导致系统始终认为有运动目标。调试阶段建议先把摄像头固定好、关闭自动对焦,再验证算法逻辑。等基本功能稳定后,再逐步加入抗干扰处理。

最后分享一个实际工程中的体会。这套代码在仿真中跑通了所有功能测试,但上板后依然花了整整两天排查DDR3时序和跨时钟域问题。仿真的价值是帮你把算法逻辑收敛到“大概率对”,但真正让系统稳定跑起来,靠的还是板级调试的耐心和对时序的敏感度。如果你正在做类似项目,建议一定要先看懂VDMA和DDR3 IP的手册,不要等到花屏了再翻文档,那时你会发现自己丢失的不只是时间,还有心态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询