做FPGA图像处理也有几年了,从边缘检测、直方图均衡到ISP管线都折腾过一遍,回头看看,最让我觉得“不难但麻烦”的其实是几何处理这一块。缩放的边界条件、旋转时的坐标抖动、不同插值算法带来的资源开销,每一个单独拿出来都能写篇文章。这篇就结合我自己做过的一个视频缩放与旋转项目,把FPGA图像变换与几何处理的架构思路、插值选型、流水线设计和调试验证串起来聊,项目不大,但覆盖的坑足够典型,适合正在入门或准备接图像类FPGA项目的朋友参考。
先说清楚这篇要解决什么问题。你在FPGA里做实时视频处理,最常遇到的就是把1080p输入缩放到720p输出、把图像做个小角度旋转校正、或者是摄像头采集的画面做一些镜像/翻转。这些操作听起来简单,但直接在FPGA里写你会发现三个麻烦:一是几何变换本质是坐标重映射,怎么高效地拿到目标像素对应的源像素坐标;二是插值怎么选,最近邻太糙、双三次资源太贵,双线性怎么用最少的DSP和BRAM实现;三是数据流怎么组织,逐行处理的视频流做几何变换天然“不对齐”,不搞行缓冲和乒乓缓存很容易把时序逼到墙角。这篇会把这三块都展开,最后配上实际工程中我能跑通的参数和代码思路。
1. 先想清楚:几何处理的整体架构怎么定
1.1 几何处理本质上是一张坐标映射表
图像几何变换,不管是缩放、旋转、平移还是透视矫正,数学上都是一件事:对目标图像的每一个像素,找到它在源图像里的位置,然后把源图像那个点的颜色值搬过来。用公式表达就是:
src_x = f(dst_x, dst_y) src_y = g(dst_x, dst_y)比如缩放就是把目标坐标除以缩放系数映射回源坐标,旋转就是乘一个旋转矩阵,透视矫正就是矩阵加归一化除法。FPGA里做这件事,核心是“反向映射”——从输出坐标算输入坐标。为什么用反向而不用正向?正向映射是从源图逐像素算目标位置,结果会留下空洞和重叠,你得再花资源去做空洞填充,这在流水线里非常难写。反向映射则是每一个输出像素恰好取一次源像素,天然无损、无空洞,硬件结构是规则的逐像素扫描,完全适配视频流的行场时序。
实际工程里,我习惯把几何处理分成三层:坐标生成层、采样层、写入层。坐标生成层用计数器模拟输出图像的行列号,然后根据变换参数算出对应的源坐标;采样层用插值算法从源图像行缓冲里取灰度值;写入层按输出时序把结果送出。三层之间用流水寄存器切开,每一级只做一点事情,时序收敛就很容易。
1.2 用反向映射思路串起整个视频通路
画一下完整的数据流你就明白为什么这个架构是顺的。输入视频流(比如1080p@60)进来后,先不做任何几何操作,而是直接按行写入一组行缓冲,这些行缓冲就是“源图像的滑动窗口”。输出侧的时序独立推进,每来一个输出像素时钟,地址生成模块就根据当前输出坐标反算出源坐标,从行缓冲里把对应的若干相邻像素取出来,送进插值模块,插值结果从输出口送走。整个过程只有行缓冲这一个“跨时钟域+跨行序”的中间点,其余全部是规则流水。
这里有个数据率的概念要算清楚:1080p60的像素时钟大约是148.5MHz,RGB888就是每像素3字节,如果你做缩放不变帧率,输出也是1080p60,那输入输出的数据率其实是相等的,整个系统吞吐量只需要维持一个像素/时钟。真正吃带宽的是DDR读写,这个后面第4节单独算。如果缩放比例特别大(比如4K降到720p),你可以让输出像素时钟降低,但多数场景下固定像素时钟、用行缓冲消解行序差更省事。
2. 插值算法:双线性为什么是FPGA上的最优解
2.1 三种插值方案的硬件代价对比
图像缩放时源坐标通常是小数。比如从1920缩到1280,缩放系数是1.5,那输出第100列的源坐标就是150.0,刚好是整数;但输出第101列对应150.67,这个0.67就是小数部分。小数坐标处没有真正的像素,只能通过周围像素加权估算。行业里常用三种方案:
- 最近邻(Nearest):取四舍五入后的最近整数像素,硬件成本为零,但图像锯齿和马赛克感明显,我一般只用在调试模式或者做快速预览。
- 双线性(Bilinear):取周围2x2四个像素,按小数部分做两次线性加权。硬件上需要2个乘法器(或者用移位近似),加上少量加法器,BRAM占用只需2~3行缓冲,是性价比最稳的选择。
- 双三次(Bicubic):取周围4x4十六个像素,用三次曲线拟合,质量最好,但硬件要4~6行缓冲加十几个乘法器,资源直接翻好几倍。实时1080p下除非你芯片资源非常富余,否则我建议谨慎。
实际工程里双线性是绝对主力。你想想,视频流本身有运动模糊、传感器噪声,双线性的轻微平滑反而能过滤掉一部分噪声,主观清晰度并不比双三次差多少,但资源省下一大半。我做缩放项目时一开始纠结要不要上双三次,后来对比实测,静止图像上双三次边缘更锐利,但动态视频里差异非常小,果断把那些乘法器省下来留给后面的ISP算法了。
2.2 双线性插值的FPGA实现拆解
双线性插值的公式可以拆成两个方向分别处理。假设算出的源坐标是(x, y),整数部分是(x0, y0),小数部分是(dx, dy),那么目标像素值就是:
P = (1-dy) * [ (1-dx) * P(x0,y0) + dx * P(x0+1,y0) ] + dy * [ (1-dx) * P(x0,y0+1) + dx * P(x0+1,y0+1) ]FPGA实现时,我不会直接按这个高维度公式铺硬件,而是拆成两步:先在水平方向对每一行的两个相邻像素做线性插值,得到中间值;再在垂直方向对两行插值结果做加权。这样每个方向都只有一个单维度插值器,模块复用起来非常干净。
关键点在坐标精度和乘法位宽。坐标小数部分我一般用8bit定点表示,也就是把浮点小数乘256后取整。这样dx和dy是0到255之间的整数,插值系数(1-dx)就是256-dx。你算一下,用9bit表示系数(因为256需要9bit),乘8bit像素值,结果是17bit,截掉低8位就是插值结果。这样整条链路全是整数运算,不需要浮点IP,两个8x9乘法器搞定。
2.3 边界处理:别让坐标跑出图像范围
边界是插值最容易翻车的地方。当源坐标小数部分落在图像最右侧一列时,x0+1就会超出有效像素范围。最简单的做法是钳位:x0超过图像宽度-2就把x0强制设成width-2,dx强制设成0,相当于在最边缘退化成最近邻。同理,y方向也这样处理。注意这里要钳位的是坐标计算阶段的“源坐标”,不是目标坐标,很多人写代码时弄混。
从调试经验看,边界像素如果处理不好,通常表现为输出图像四周出现细黑边或者颜色突变条纹。原因是越界读到了行缓冲的未初始化数据。如果你用BRAM做行缓冲,未初始化区域上电后是随机值,表现就是白噪或花点。钳位逻辑一定要在流水线的地址生成模块里做,不要等到采样模块再判断,否则时序上容易多出来几级组合逻辑。
3. 流水线设计:行缓冲与乒乓缓存,解决“既要快又要准”
3.1 为什么非用行缓冲不可
视频流是逐行扫描的,但双线性插值需要访问上下相邻两行、左右相邻两列共4个像素。如果你直接给SDR/DDR发读请求,旋转角度稍大一点,需要的坐标会跨很多行,实时性根本扛不住。行缓冲的本质就是:在FPGA内部用BRAM暂存最近几行数据,让采样模块能够在一个时钟周期内同时拿到4个像素。
具体来说,垂直方向用N行缓冲,水平方向通过移位寄存器打拍。以双线性为例,我需要2行缓冲,再加一组寄存器把当前行的连续两个像素取出来。结构是这样:输入像素流先写入行缓冲阵列,每来一个新像素,把它写入第0行缓冲,同时第0行读出旧数据写入第1行缓冲,第1行读出旧数据直接丢。这样任意时刻,第0行和第1行缓冲的输出正好对应垂直方向相邻两行的同一列数据,再分别打一拍错开一列,就凑齐了2x2的窗口。
旋转任意角度时需要的窗口更大。比如旋转45度,双线性插值在目标像素的源坐标周围取2x2,理论上行缓冲深度只要满足源图像行跨度就行——也就是目标图像一行反算回源图像,最多跨越多少行。对于90度以内的旋转,按对角线方向取行,2~3行不够,我实测大概需要源图像的行数等于目标高度乘以旋转角度的正切再加几行余量。保守做法是直接按最大对角线跨度准备行缓冲数,比如6~8行,这样缩放旋转都能覆盖。
3.2 行缓冲的BRAM资源精确计算
BRAM预算要提前算,别等综合报错再改。假设输入是1080p的RGB,每像素3字节,一行1920像素就是5760字节。双线性需要2行缓冲,共11520字节。如果用Xilinx 7系列,一个Block RAM是36Kbit,即4.5KB,3字节划分方式下需要约3个BRAM(因为跨字节划分还要考虑数据位宽利用率)。我做旋转时需要8行缓冲,那就是约46KB,约11个Block RAM。对大部分中端芯片来说,比如Artix-7 35T有50个BRAM,占比不到四分之一,完全能接受。如果你用ZYNC系列还要给DDR缓存和帧缓存留BRAM,心里要有个总预算表。
位宽上建议按像素分通道处理。R/G/B三个通道并行插值,各用各自的小乘法器,但行缓冲可以按像素打包存储。比如RGB888打包成24bit存入BRAM,读出来后再拆成三个通道,这样BRAM的读写端口只占一套,吞吐效率最高。灰度图更简单,8bit宽度,2行缓冲只需要2个BRAM的一半还不到。
3.3 流水线切分与帧同步信号
流水线不切分,时序收敛是你最大的噩梦。我习惯把整条几何处理链路切成4级:
- 第一级:地址生成,根据输出计数器算源坐标,完成整数/小数拆分和边界钳位。
- 第二级:行缓冲读取,从BRAM里取出4个窗口像素。
- 第三级:水平插值,两个水平方向的单维插值器并行工作。
- 第四级:垂直插值和输出同步,把上一级两个中间值合并,同时把输出行的场同步信号对齐。
每一级之间插入流水寄存器,这样组合逻辑深度被限制在合理范围,148.5MHz在Artix-7上可以无压力收敛。这里有个细节:行场同步信号也要跟着打拍。视频流的de/active信号如果和像素数据同级流动,插值操作天然会引入4~5个时钟的延迟,你必须在每一级把valid信号、行号计数、帧计数同步打拍,不然输出图像会出现“左上角错位”或者“帧尾多一行”的问题。我见过很多工程在这个地方翻车,现象是画面边缘有整齐的偏移条带,排查半天发现就是sync信号没对齐。
乒乓缓存是另一块常用结构。如果你接入的是DDR帧缓存,写侧读侧同时访问,单端口DDR带宽容易被切成两半。乒乓缓存的思路是:用两组BRAM/寄存器阵,一组接收写入、一组用于读出,交替切换。写入的是一帧的某几行,读出的是另一帧的不同行。好处是读写两边不需要抢占同一个DDR bank,带宽利用率能提升到接近90%。我做多端口DDR读写程序时,还在此基础上加了每个端口的独立地址生成和突发长度控制,实测4端口并发时效率从57%提上来了不少。
4. 实战:旋转、缩放、镜像的实现与带宽预算
4.1 任意角度旋转:CORDIC还是查表
旋转是最考验地址生成模块的操作。标准旋转公式是:
src_x = dst_x * cos(a) + dst_y * sin(a) src_y = -dst_x * sin(a) + dst_y * cos(a)这里有个坐标中心问题。以图像左上角为原点旋转,图像会整体甩出视野;以图像中心为原点旋转,视觉上才正常。实际使用时我会先做坐标平移:把目标坐标减去中心点,旋转完,再加上源图像的中心点,得到源坐标。
FPGA里算三角函数两条路:查表和CORDIC。如果旋转角度是固定的(比如硬件矫正固定视角偏差),查表最简单——用MATLAB或Python预生成cos和sin的定点值,存成ROM,一个ROM输入角度索引输出cos/sin,两个乘法器完成坐标变换。查表精度取决于角度分辨率,我用16bit定点,角度按0.1度量化,实测坐标误差小于0.02个像素,完全够用。
如果角度是实时变化的(比如云台实时矫正),就要上CORDIC IP。Xilinx和Intel都有现成的CORDIC IP核,旋转模式下可以直接输出cos和sin。但注意CORDIC有迭代延迟,大约16到20个周期,你要在地址生成流水线里为它预留对应的延迟补偿,也就是让输入坐标先等CORDIC算完再进乘法器。很多人忽略这点,结果算出来的坐标全是乱的。我在第一次做实时旋转时就被这个延迟坑过一次,现象是旋转后的图像在边缘出现随机错位方块,后来在valid信号上做了对齐才恢复正常。
4.2 90度整数旋转:转置存储,避免插值造轮子
任意角度旋转需要插值,但90度整数旋转有更聪明的做法,不用插值也不会损失画质。90度旋转本质是行列转置加镜像。如果图像存在DDR帧缓存里,90度旋转就是改变行写列、列读行的地址映射关系,完全靠地址逻辑实现,不需要行缓冲也不需要插值模块。
具体来说,假设源图像宽W高H,存到DDR时按行优先。旋转90度后的目标图像宽H高W,目标第j行第i列对应源图像第(W-i-1)行第j列(顺时针90度)。在FPGA里写DDR时,写地址按源图像正常顺序写;读地址按转置后的映射算。如果DDR的跨页开销很大,建议把一行的数据按突发长度拆分缓存,转置时以行为单位做乒乓。180度和镜像变换就更简单了,横坐标取反或者W-1-x,纯粹是坐标一维翻转,连DDR特殊映射都不需要。
我的经验是:在系统设计阶段先问清楚需求是“任意角度旋转”还是“仅90度/镜像翻转”。如果是前者,行缓冲+插值是必须的;如果是后者,在DDR读写层面就把旋转做了,能省掉一整块插值流水线。这个决策直接决定你的FPGA资源够不够用,很多项目失败不是因为算法难,而是需求没问清楚导致过度设计。
4.3 带宽预算:算清DDR的资源账
几何处理无论怎么整,数据来源和去向多是DDR。带宽预算必须一开始就拉清楚。以1080p60 RGB888为例,一帧是1920x1080x3字节约6.22MB,60帧每秒就是约373MB/s的原始带宽。如果输入帧从DDR读、输出帧写回DDR,总共需要约746MB/s。DDR3-1600的理论带宽是12.8GB/s,但实际读写效率因为刷新、bank冲突、命令开销,通常只有理论值的60%到70%,也就是8~9GB/s。看起来富余很多,但一旦你接入多个图像通道或者跑ISP多级处理,带宽会迅速吃紧。
旋转和缩放场景下带宽还有个隐藏放大器。旋转非90度时,源图像的一行读取跨度可能对应输出好几行,如果行缓冲深度不足以覆盖,模块会反复回到DDR取数。比如30度旋转,跨行跨度大约为输出高度的0.58倍,1080p下就是约620行。如果你的行缓冲只有8行,访问模式会变成“每输出8行就回DDR重新读一大块”,实际带宽消耗可能翻到原始带宽的3到5倍。这也是为什么很多旋转项目被迫用更大的行缓冲或者直接在DDR侧做分块缓存。
这里有三个减负技巧:一是缩小插值窗口,能用2x2绝不用4x4;二是输出路径不要备份另一份完整帧,裁剪尺寸实时算;三是对旋转缩放组合操作,先缩后转比先转后缩少读一次DDR。我在做边缘网关通信测试终端的画面矫正时,就是用“先缩放至目标分辨率再做小角度矫正”,把带宽从估算的1.8GB/s压到了820MB/s,整条链路才跑稳。
5. 我在调试中踩过的坑与排查清单
5.1 坐标复位抖动与亚像素“跳点”
这是缩放/旋转项目里最经典的bug。现象是:静止画面下,图像某几条竖线位置不停抖动,感觉整个画面在“呼吸”。我用ILA抓数据发现,源坐标小数部分在某个边界值附近抖动,比如dx在127和128之间反复跳变,导致插值权重突变,像素亮度明显闪动。根因有两个:一是坐标计算用了浮点然后截断成定点,截断边界上浮点误差会放大;二是浮点转定点时用了四舍五入,而硬件角度的系数复位没做同步。
解决方法是把坐标计算全部改到定点域,而且是用“向下取整+余数”的方式,而不是四舍五入。具体到代码上:坐标乘系数时,结果的高位是整数部分、低位是小数部分,直接截断低位就得到整数坐标,不要额外加0.5。这样在边界上行为是确定的,不会因为四舍五入在不同帧之间抖。还有个关键点:所有流水线内部的计数器、坐标寄存器,复位时要用同一个使能信号同步,避免因复位沿不一致导致坐标基准偏移。
5.2 行缓冲的读冲突与写覆盖
行缓冲的读写端口冲突是另一个高频问题。BRAM的简单双端口模式支持同时一读一写,但如果你在同一个时钟既往第0行写入新像素又从第0行读出旧数据,就会冲突。解决思路很直白:把BRAM拆成两个bank,奇数行写入bank A,偶数行写入bank B,读写交替访问,用bank切换状态机来相位错开。或者你可以把读操作提前一拍:寄存器里保存当前行的“延迟一拍”数据,读出操作永远访问的是上一行已经稳定写入的数据。代价是增加一组寄存器,但换来的是端口彻底解除耦合。
另外,行缓冲空满状态一定要用valid信号管理,不要用count==0这种组合逻辑判断。视频流有突发(比如DDR读回数据时按burst到达),行缓冲写入是不均匀的,如果valid和ready信号没有按AXI Stream的规范打拍,很容易出现“断行”——表现为图像每隔几行缺一条线,颜色错位。我后来统一改成参考AXI-Stream的ready/valid握手,在行缓冲入口做了一个FIFO缓冲来平滑突发,问题彻底消失。
5.3 时序收敛不顺时的三板斧
做旋转+插值项目,最容易时序不过的地方是坐标生成的乘法链。一个输出像素要同时算src_x和src_y,每个涉及两次乘法加一次加法,组合逻辑很容易超过一个时钟周期。我的优化顺序是:
- 第一斧:乘法器加流水寄存器。DSP48本身支持级联,把乘法结果寄存一拍再接加法器,频率立刻能提起来,代价是坐标延迟多一个周期,记得补偿行同步信号。
- 第二斧:把系数预处理成“归一化权重”。例如把cos和sin系数组合成cos/sin对表,预先算好两者的定点值,避免在路径上做除法。除法是FPGA最贵的运算之一,能在MATLAB里算完就别让硬件做。
- 第三斧:多通道并行改时分复用。如果资源紧张,RGB三通道可以共用一套插值器,每个像素分3个时钟算完R、G、B。帧率不变,但DSP用量降到原来的三分之一,代价是行缓冲宽度变大,BRAM用量略增,综合起来往往更划算。
我在实际项目里三斧头都用了:第一斧解决频率,第二斧把关键路径从9级组合逻辑降到了5级,第三斧在资源紧张的芯片上保住了功能。这套组合拳下来,148.5MHz在Artix-7上能直接收敛,不需要再跑去改布局布线策略。
5.4 FPGA图像几何处理的复杂度如何评估
最后说一个经验性的复杂度评估方法,方便你在项目启动时给领导或客户一个靠谱的交底。最小系统(固定比例缩放+最近邻)大概1个人/周;固定比例缩放+双线性插值大概2~3人/周;任意角度旋转+双线性插值+动态参数约4~6人/周;加上透视矫正、多路视频、动态帧率调整,至少8人/周起步。评估时别忘了仿真和板级调试的时间,两者在各占一半。热词里经常出现的“基于FPGA的多端口DDR读写程序”“理想流水线CPU设计”这些方向,如果项目是第一次做,我强烈建议先花一天时间在开发板上跑通DDR读写和一个极简缩放模块,扫清环境和工具链的坑,再开始算法移植,能省下一周甚至更多的无效调试时间。
6. 工具链与验证:仿真到上板的闭环
因为经常有人问FPGA图像项目怎么保证一次性调通,这里补充一下我个人的验证流程。第一步,用Python或MATLAB生成带特殊标记的测试图(比如棋盘格、渐变圆),金色参考模型用同样的插值算法在PC上算出预期输出。第二步,写一个testbench把测试图按视频流格式灌进仿真,输出存成BMP对比,像素误差要在1个灰度级内(定点误差造成的2%以内误差算正常,超过说明代码有bug)。第三步,板级调试时用ILA抓关键节点的坐标和像素值,和仿真波形对照。如果仿真和板级结果不一致,优先查时钟域跨接和复位同步。我习惯把testbench按模块拆开:地址生成模块单独仿、插值模块单独仿、顶层联合仿,定位问题用二分法缩小到具体模块,而不是一上来就跑全链路仿真。
https://www.zhihu.com/education/video/1637194900221616130
有几次在板子上调旋转,图像出来整体偏斜了一个固定角度,我一度以为是旋转矩阵系数错了,后来通过把源坐标直接导出到文件比对,才发现是行缓冲的读写地址用了目标图像坐标而不是源图像坐标。这类“坐标基准”错误在仿真里极难发现,因为输出像素值本身看起来是“连续”的,只有和参考模型逐像素对比才暴露。所以项目开始收敛前,AI模型级的逐像素对比必须坚持做。这不是效率低,而是你唯一的“真相来源”。
另外多说一句,如果你的旋转角度是运行时通过ARM处理器动态配置的,那就涉及到软硬件接口设计。我一般用到的是AXI-Lite从接口,把角度、宽度、高度这些参数映射到寄存器,FPGA侧地址生成模块每帧开始时锁存一次这些参数。因为参数更新可能发生在帧中间,直接改会撕裂画面,必须用帧同步信号打拍——在vsync有效时把新参数载入内部寄存器,保证一帧内参数一致。这个细节很多人忽略,结果配置角度后画面在帧中间出现一条跳跃分界线,调试起来非常迷惑。
最后再分享几个小技巧
双线性插值的系数表可以预生成,但不要存浮点,8bit定点足够,128个角度分度内基本看不出区别。
如果你在做多通道(比如双目摄像头)几何校正,插值和坐标模块可以完全复用,只要把行缓冲和DDR通道加倍,逻辑资源不会线性翻倍,因为控制逻辑是单份的。
有些芯片的DSP48带有预加器(pre-adder),双线性插值的水平加权可以先加后乘,直接在DSP里完成两个乘法一个加法,位宽利用更充分。ARM/Xilinx的文档里对这种用法有详细示例,写代码前值得翻一下。
行缓冲的深度在设计阶段就按“最大旋转角度+缩放比”的余量取,不要抠到刚好够。因为一旦需求方后面改了分辨率或者加了透视矫正,行缓冲深度不够,你就要花两个星期改架构,不如一开始多预留几行BRAM。
我这个项目最后把缩放、90度旋转、任意角旋转、镜像四种模式都做进了一个IP核,通过寄存器选择工作模式,共用一套行缓冲和插值流水线,资源比单独实现四个模块节省了差不多45%。如果你们也有多种几何需求,强烈建议做成模式可配的单一处理核,架构上稍微多花点心思,后期维护和复用会轻松非常多。