ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA视频采集链路实战:从Bayer到HDMI的完整工程解析

FPGA视频采集链路实战:从Bayer到HDMI的完整工程解析

简介:一个面向全高清摄像头应用场景的FPGA工程,支持Bayer RAW到RGB图像转换及3A功能(自动曝光、自动白平衡、自动对焦),适合具有一定基础的FPGA开发者、嵌入式视觉方向学生参考。资源以zip压缩包形式提供,体积33.42MB,目前已有200人浏览学习。工程对应rev.3硬件,使用时仍需MCU工程配合完成传感器配置与无线控制;而按描述,rev.4将是交付版本,届时MCU与FPGA可共同完成这些功能。通过该工程可了解FPGA在图像采集链路中的角色,包括Bayer数据解析、RGB重建与3A控制流程,也能看到硬件方案从外置MCU逐步整合到FPGA的整体演进思路,对开展全高清相机或视觉类项目有一定借鉴价值,而Bayer数据解析与RGB重建正是理解CMOS图像传感器输出的关键一环。3A中的自动曝光、自动白平衡和自动对焦是相机成像质量的核心,也是该工程的主要亮点,开发者可借此理解算法与FPGA流水线的结合方式。

1. 全高清摄像头在 FPGA 上落地:这包资源不只是一段 Verilog

做视频采集的人应该都有过这种经历:手里有一颗 1080p 的 CMOS sensor,数据手册写得清清楚楚,结果一上板全是花屏、偏色、横纹,调了三天发现是 Bayer 顺序搞反了。我拆过一套配套源码和文档齐全的 FPGA 采集工程,它把 sensor 驱动、Bayer 转 RGB、HDMI 显示一整条链路都串了起来。这套资料的核心价值不在“能点亮”,而在于它把 Bayer 到 RGB 的插值算法、行缓存设计、DDR3 带宽规划这些硬骨头做成了可复用的工程模块,硬件语言和 C 模型对照着看,能少走很多弯路。适合正在做工业相机、医疗内窥、车载影像的开发者,也适合想从仿真走向上板验证的学生。如果你想在 FPGA 上跑通一条完整的视频采集链路,这份工程值得扒一扒。

2. 数据链路架构:从 sensor 输出到 Bayer 对齐的完整通路

2.1 sensor 输出格式与行场同步信号解析

先看最基础的部分:sensor 输出的原始数据长什么样。全高清(1920×1080)的 CMOS sensor 一般支持 DVP 和 MIPI 两种接口。DVP 接口相对简单,包含 PCLK、VSYNC、HSYNC、DATA 几根信号线;MIPI 接口则是差分串行,需要做串并转换。这份工程里给出的 DVP 接口代码对新手特别友好,它把 sensor 的时序参数(比如行消隐、场消隐、有效像素区间)全部做成了可配置的宏定义,换 sensor 时只需要改参数表,不需要改逻辑。

// sensor 时序参数配置 `define LINE_VALID_CNT 1920 // 有效像素数 `define FRAME_VALID_CNT 1080 // 有效行数 `define H_BLANK 220 // 行消隐,单位:PCLK `define V_BLANK 45 // 场消隐,单位:行 `define DATA_WIDTH 12 // sensor 输出位宽,常见 10/12/14bit

这段配置是 DVP 采集的核心。LINE_VALID_CNT和FRAME_VALID_CNT直接对应 1080p 的分辨率,H_BLANK和V_BLANK则需要根据具体 sensor 手册来填,不同型号差异很大。DATA_WIDTH是容易踩坑的位:sensor 输出 10bit 还是 12bit,直接决定后面 Bayer 插值时的位宽选择。常见做法是高位对齐存入 16bit 寄存器,低位补零,这样在后续的加法运算中可以尽量减少截位误差。

行场同步信号的处理逻辑上,我一般会用延迟打拍的方式做边缘检测。VSYNC 的上升沿表示一帧开始,HSYNC 的上升沿表示一行开始。需要注意的是,有些 sensor 的 HSYNC 是低电平有效,如果照抄参考设计可能会造成行错位。这个工程里做了一个输入极性自动适配的模块,通过寄存器配置选择高/低有效,换不同厂家的 sensor 时不用改逻辑,写寄存器就行。

2.2 DVP 接口的采集时序约束与像素时钟计算

DVP 接口的时序核心是 PCLK。1080p30 的 sensor,PCLK 通常在 60~75MHz 之间;1080p60 就需要 120~148.5MHz。像素时钟的计算公式很简单:

PCLK = 行总像素 × 帧总行数 × 帧率 = (1920 + 220) × (1080 + 45) × 30 ≈ 73.4MHz

这个计算决定了 FPGA 侧的时钟域设计。工程代码里用的是 PCLK 直接驱动采集逻辑,再通过异步 FIFO 把数据从 PCLK 域切换到系统时钟域(比如 150MHz)。异步 FIFO 的深度选择要看跨时钟域数据量:每行 1920 个有效像素,加上消隐期,FIFO 深度设 4096 就足够,不用贪大。

工程里针对 PCLK 和系统时钟做了约束文件,具体的做法是把所有采集相关的寄存器用set_false_path跨时钟域约束,避免时序分析器把异步路径当作同步路径去卡。初次上板出现偶发花屏时,优先检查是否缺少这些约束——很多人只在功能仿真里跑通了,综合实现后时序乱掉,问题就出在这里。

2.3 MIPI 接口简述与差分转单端方案

如果 sensor 是 MIPI 接口,就不能直接接 FPGA 的普通 IO。常见的做法有两种:一是买一颗 MIPI 转 DVP 的桥接芯片,比如某款常见的 CSI-2 转并行芯片,代价是增加了物料成本和 PCB 面积;二是用 FPGA 内置的 MIPI IP,但需要选支持 MIPI 的 FPGA 型号,比如带高速收发器的中高端芯片。这份工程主要用 DVP 方案,但文档里也提到了 MIPI 转接后的时序对齐问题,核心是 data lane 的 word 对齐。MIPI 的 HS 模式下数据是 8bit 对齐的,如果 lane 的 4 根线在 PCB 上等长没做好的话,容易出现通道偏斜,表现为图像上随机出现错位的色块。

3. Bayer 转 RGB:插值算法与缓存结构的权衡

3.1 双线性插值:三行缓存与窗口生成

Bayer 阵列的每个像素只记录 R、G、B 中的一个颜色分量,要得到完整的 RGB 图像,必须通过邻域像素插值补齐另外两个分量。最简单的双线性插值,就是用 3×3 窗口内的同色像素取平均。工程代码里用行缓存实现 3×3 窗口,这部分逻辑是整个图像处理管线的地基。

// 三行缓存生成 3×3 窗口 // 使用移位寄存器实现行延迟 wire [DATA_WIDTH-1:0] line_data_0; // 当前行数据 wire [DATA_WIDTH-1:0] line_data_1; // 延迟1行 wire [DATA_WIDTH-1:0] line_data_2; // 延迟2行 // 窗口内三个行样本 reg [DATA_WIDTH-1:0] win_00, win_01, win_02; reg [DATA_WIDTH-1:0] win_10, win_11, win_12; reg [DATA_WIDTH-1:0] win_20, win_21, win_22; always @(posedge pclk) begin // 每个时钟周期滑一个像素,窗口内容逐列刷新 win_00 <= win_01; win_01 <= win_02; win_02 <= line_data_0; // ... 以此类推其他行 end

行缓存的实现有多种方式:用 Block RAM 的移位寄存器模式、用分布式 RAM、或者直接用寄存器数组。这个工程选的是 block RAM 的移位寄存器模式,因为 1920 像素一行,12bit 位宽,约 23Kbit 数据,用分布式 RAM 会占用大量逻辑资源。需要注意的一点是,行缓存的读出延迟是固定的,3×3 窗口的数据必须严格对齐,否则插值结果会出现边缘偏色,这在工程代码里用了一组打拍寄存器来匹配延迟。参数上,DATA_WIDTH决定缓存位宽,LINE_VALID_CNT决定缓存深度,如果改成 4K 分辨率,这两个参数都要对应调整。

3.2 边缘自适应插值:梯度方向判断与伪彩抑制

双线性插值的缺点是边缘处容易产生“拉链”现象和伪彩色。原因是它对水平和垂直方向的边缘没有区分,直接平均导致过渡带出现不自然的颜色。工程代码里实现了一个改进版本——基于梯度判断的边缘自适应插值。核心思路是:在 G 通道插值时,先计算水平梯度和垂直梯度,如果水平梯度小,说明边缘是垂直方向的,应该用水平邻域的 G 值平均;反之用垂直邻域。这样插值出来的 G 通道在边缘处更加锐利。

// 梯度计算与方向判断 - 在 G/B 像素位置插值 G 分量 // 以 3×3 窗口中心点为例 // G1 G2 G3 // G4 [B/R] G5 // G6 G7 G8 // 但 R/B 位置需要插值 G int grad_h = abs(win_01 - win_21); // 水平方向梯度(上下两行的 G 差分) int grad_v = abs(win_10 - win_12); // 垂直方向梯度(左右两列的 G 差分) if (grad_h > grad_v) begin // 垂直边缘,用左右邻域 G 平均 g_interp = (win_10 + win_12) >> 1; end else if (grad_h < grad_v) begin // 水平边缘,用上下邻域 G 平均 g_interp = (win_01 + win_21) >> 1; end else begin // 平坦区域,用四邻域平均(或用对角插值) g_interp = (win_01 + win_21 + win_10 + win_12) >> 2; end

这段逻辑的代价是增加了比较器和绝对值运算,在 FPGA 上是纯粹的 LUT 消耗。实测下来,以 1080p30 为例,边缘自适应比纯双线性插值多消耗约 2%~3% 的 LUT 资源,但图像边缘的锯齿和伪彩明显改善。工程里把这个算法做成可配置的,通过寄存器可以选择双线性还是边缘自适应,方便做画质对比。如果只做预览、不追求边缘锐度,用双线性就足够了,能省下资源给其他处理模块。

3.3 自动白平衡与色彩校正矩阵的简配实现

拿到 Bayer 插值后的 RGB,还不能直接用。CMOS sensor 的原始响应和人的视觉差异很大,直接映射到显示器上会偏冷或偏暖,白平衡和颜色校正这一步省不掉。工程里提供了一个简配方案:基于灰度世界假设的白平衡。它的做法是统计一帧图像中 R、G、B 三个通道的均值,然后计算增益系数,将 G 通道均值作为基准,R 和 B 分别乘以对应的增益,使三通道均值接近。

// 白平衡增益计算(简化版) // 统计量来自上一帧的均值,当前帧使用 reg [31:0] sum_r, sum_g, sum_b; // 一帧累加和 reg [17:0] avg_r, avg_g, avg_b; // 均值,截取高位 // 计算增益,固定小数位 Q12 格式 reg [15:0] gain_r, gain_b; // gain_r = (avg_g << 12) / avg_r; // gain_b = (avg_g << 12) / avg_b; // 应用增益 rgb_r_balanced = (rgb_r * gain_r) >> 12; rgb_b_balanced = (rgb_b * gain_b) >> 12;

这样做的好处是省掉了复杂的统计模块,只用一个帧累加器和除法器。除法器可以用 LUT 查表替代,或者直接例化除法器 IP。工程里建议用移位加迭代的方式实现除法,虽然耗时但只在一个帧间隙(垂直消隐期)计算一次,对性能没有影响。色彩校正矩阵(CCM)在工程里是留给用户的扩展接口,默认是单位矩阵。如果后续要过 DNP 认证或者追求色彩还原,再通过 3×3 的矩阵乘法替换即可。矩阵系数的标定需要对着色卡拍图,用最小二乘法拟合,这个工程没有做自动化工具,但预留了 reg 接口,可以在嵌入式处理器里写系数。

4. DDR3 缓冲与帧率匹配:带宽估算和链路设计

4.1 不缓存行不行?带宽计算与帧缓存的意义

如果只是做 1080p30 的纯直通显示,不经过 DDR 缓冲理论上也能跑通:sensor 数据逐行进来,插值后逐行送给 HDMI / LCD 显示。但实际场景几乎都要求帧缓存。原因有两个:一是 sensor 的输出帧率和显示器的刷新率不一定精确匹配,比如 sensor 输出 30fps,显示器是 60Hz,如果不缓存直接送显,画面会因为帧率不同步而撕裂;二是后续的缩放、OSD 叠加、图像增强处理都需要在帧级别随机访问,不做缓存就只能做逐行流水。

所以帧缓存是必需品。工程里用的是 DDR3,带宽需要先算清楚。1080p30、RGB888 格式,一帧数据量大约是 1920×1080×3 ≈ 6.2MB,30fps 就是 186MB/s 的写入流量。如果同时支持 1080p60 输出,考虑读写并发,DDR3 的带宽需求大概是读 186MB/s + 写 373MB/s ≈ 560MB/s。DDR3 的物理带宽通常能达到数 GB/s,看起来绰绰有余,但要考虑 DDR3 控制器本身的效率损耗和 Bank 切换开销,实际可用带宽通常只有理论值的 60%~70%。因此在做 FPGA 逻辑的时候,总线位宽尽量做到 AXI4 接口的标准位宽,否则带宽不够就会直接断帧。

4.2 AXI4 接口与三帧缓存的读写策略

工程里的 DDR3 读写使用的是 AXI4 接口。Xilinx 风格的习惯是例化 MIG 核,然后在用户侧再加一层 AXI 转接逻辑。这个工程提供了一套简化版的 AXI-Lite 和 AXI-Full 控制器逻辑,用户逻辑不需要直接面对 DDR3 的时序,只需要按 AXI 的通道协议读写地址和数据。虽然工程没有用现成的 VDMA,但它的读写模块结构和 VDMA 的思路一致。

三帧缓存的设计要点是,在内存里分配三个帧缓冲区,对应地址为 BASE_ADDR、BASE_ADDR+FRAME_SIZE、BASE_ADDR+2×FRAME_SIZE,采用“写作入一帧,读出一帧,第三帧等待”的轮转方式。写指针和读指针之间必须保持至少一帧的间隔,否则会产生画面撕裂。工程代码里的核心逻辑是一个帧指针仲裁器:

// 伪代码:帧指针管理 uint32_t WriteFrameAddr; uint32_t ReadFrameAddr; if (FrameSyncIn) { // sensor 帧同步到来 WriteFrameAddr = NextWriteFrame(); // 切换写目标帧 } if (DisplayVsync) { // 显示同步信号 ReadFrameAddr = OldestReadyFrame(); // 切到最早完成的帧 }

这里面有一个很容易踩的坑是帧覆盖问题。如果写指针追上读指针,把正在显示的那一帧数据给覆盖了,画面会出现上半帧和下半帧不是同一时刻图像的“滚屏”现象。解决办法是,读指针切换时检查当前写指针的写入状态,如果正在写某一帧,就不切换读指针到该帧。工程代码里在各帧的标识寄存器中维护 busy 标志,这个细节是判断工程是否严谨的关键点。

4.3 数据位宽与字节序的跨端匹配

从这个工程的实际调试经验来看,有个问题特别隐蔽——字节序。sensor 输出的 Bayer 数据如果是 10bit 或 12bit 打包格式,比如 1920 个像素、每个像素 12bit,一行就是 23040bit,不按字节对齐。DDR3 读写按 64bit 或 128bit 对齐,中间需要做数据重排。工程代码里做了一个线缓存的打包模块,把 2 个 12bit 像素拼成 3 个 8bit 字节,然后再判断起始字节位置。这个打包逻辑如果写错,图像会出现“拧巴”的现象——每一行都是错位的,呈现阶梯状斜纹。排查方法很简单,输出一个已知的彩色测试图(比如红绿蓝渐变),如果渐变方向是斜的,就是打包对齐错了。

5. 上板调试避坑:花屏、错位与偏色的排查路径

5.1 全黑屏:查 PCLK 和复位时序

现象:sensor 配置正确,I2C 也正常初始化,但画面完全黑屏,偶尔有一两条白线闪一下。

原因很可能是 PCLK 没有正常达到采集模块。DVP 接口的 PCLK 一般是 sensor 输出的,如果 sensor 配置内部 PLL 异常,或者锁相环未锁定,PCLK 可能没有输出,采集模块一直停在等时钟状态。另一种可能是复位信号释放过早,sensor 内部的算法引擎没准备好。

解决:先用示波器或者 ILA(集成逻辑分析仪)观察 PCLK 引脚是否有时钟跳变。如果 PCLK 正常,再检查行场同步信号的极性,有时候 sensor 输出的 VSYNC 是低电平有效,代码里没匹配上,导致采集模块一直认为帧没有开始。常用做法是在视频接口模块里加上极性配置寄存器,上电后从寄存器空间探测,能够避免这类问题。

5.2 花屏且噪点分布诡异:跨时钟域路径没约束

现象:图像能出来,但整幅画面有大量随机夹杂的彩色噪点,像是信号被干扰。用示波器测 PCLK 和数据线,电平都是干净的。

原因:这是跨时钟域采集数据时发生的亚稳态,本质上是 PCLK 域的传感器数据被系统时钟域的 FIFO 读走了,但没有做合理的同步处理。如果采集模块直接用系统时钟去采样 PCLK 域的信号,不加两级同步器,亚稳态概率会急剧升高。

解决:在工程代码里找到跨时钟域读指针相关的逻辑,检查是否例化了标准的异步 FIFO,包括格雷码指针同步。多数情况下,异步 FIFO 只需要把写侧和读侧指针的格雷码同步到各自另一端即可。可以不过仿真直接上板验证——异步 FIFO 的仿真行为正确,不代表实际时序合格,尤其是复位释放的顺序和异步 FIFO 的复位置位时间,需要认真检查。

5.3 偏色严重:Bayer 顺序排错

现象:图像整体偏绿或偏紫,黑白场景下尤为明显。

原因:这是最常见的问题之一,原因是贴片焊接时 sensor 的管脚顺序和 PCB 布局方向,或代码中设定的 Bayer 起始位置与实际的 RGGB 排列不一致。Bayer 阵列有四种可能顺序:RGGB、BGGR、GRBG、GBRG。如果代码里默认是 RGGB,实际 sensor 输出是 BGGR,插值后的颜色就完全错乱。

解决:工程代码里提供了一组 Bayer 顺序配置寄存器,把BAYER_PATTERN按参数重新编译,或者写寄存器切换。一次调不好就画一个简短的测试色卡,用固定颜色与帧画面对照,逐个试验四种顺序,直到色彩恢复正常。

5.4 画面边缘撕裂:读写指针冲突

现象:液晶屏显示正常的大部分区域,但帧切换时的某一两行会出现条纹。

原因:显示控制器按行读取 DDR3 数据,如果读取点正好碰上写入点,就会出现半帧数据的交错。前面提到过读写指针需要至少一帧的间隔,但如果帧率匹配时机不好,读指针偶尔会被写指针“追尾”。

解决:把帧轮转策略改得更保守一些——读指针不再指向最新完成帧,而是指向次新帧,牺牲了大约 1 帧的延迟(约 33ms),但可以完全避免读写碰撞。工程代码里默认用三帧缓存轮转,并以消隐期作为帧切换窗口,如果仍偶发撕裂,就检查判断 busy 标志的逻辑。

5.5 1080p60 上不去:DDR3 带宽卡死

现象:1080p30 一切正常,将 sensor 和显示配置改为 1080p60 之后,画面出现周期性断帧,帧率上不去。

原因:带宽不够。之前算过写 373MB/s、读 186MB/s,合计 560MB/s。实际 DDR3 控制器如果是 16bit 位宽、工作频率 400MHz,带宽理论值为 800MT/s × 2Byte = 1.6GB/s,但要打 7 折,实际可用 1.1GB/s 左右,理论上够用。关键可能出在 AXI 总线效率上,如果每次读写都发起短突发(比如 32Bytes),控制器效率会急剧下降,大量时间被浪费在行切换和预充电上。

解决:把 AXI 突发长度统一改成 256Bytes,并且把图像行数据在内存中连续存放。这样可以大幅提高行命中率。这套工程的 DDR3 模块源码里就是这么写的,但如果你修改了参数或者换了 IP,就要重新检查每个驱动的 burst 长度设定。

6. 进阶验证技巧:用色卡和 ILA 校准整条链路

工程代码给了一个 16 位色彩条生成器,可以在 sensor 输入的接口处直接插入测试图案,这样可以单独验证 Bayer 转 RGB 模块和 DDR3 通路的正确性。我拿到这套代码后,习惯的验证顺序是:先跑彩条,再送 sensor 数据,最后做颜色校准。

第一层,验证 Bayer 插值和显示链路。把 test pattern 模块接到采集模块的数据出口,发送全红、全绿、全蓝的画面,观察显示屏输出是否对应的纯色。如果全红画面偏紫,说明插值的 G 通道数值偏高,检查白平衡增益是否错误地作用在测试模式上。这一步能直接找出算法逻辑的问题。

第二层,验证 DDR3 读写控制。用 ILA 抓写 AXI 总线的 AWLEN 和 WLAST 信号,确认写突发长度和设计一致。然后抓读地址对应的数据,与写入数据对比。工程代码里没有提供自动比对逻辑,但 ILA 触发器可以设置条件:当读地址在某个范围内,抓取数据,和测试图案的期望值比对。

第三层,也是最容易被忽略的,是用“画质评估的土办法”来验证边缘自适应插值的实际效果。把一张明显边缘特征的测试图,比如棋盘格或者斜线图,放到 sensor 前面拍,然后在 FPGA 输出端截取一帧,放大观察边缘是否有拉链或彩色边条。这一步靠人眼判断即可,不需要计算 PSNR 或 SSIM。但如果你想量化,可以在插入测试图案时直接用片内 RAM 存两帧结果(双线性插值和边缘自适应插值各一帧),在处理器上离线计算 PSNR,差分明显的区域,就是插值算法需要优化的地方。

补充一个实用技巧:对 ILA 抓到的数据导出为 CSV,再在 Python 里恢复成图像,这样能更快地定位问题行和问题列。如果显示输出端有问题,这一招能直接验证 DDR3 读出的数据是否真的是预期值。

整套链路跑通之后,我每次改完代码都会强制走一遍这个流程:先测试图案,再 sensor 实拍,再量化比对。这套工程的代码结构把每个模块都隔离开了,测试图案模块和 Bayer 插值模块之间的接口信号留得很清晰,让我在排除问题时不用翻整个工程重读,只需要替换信号源就行。如果你也遇到类似的 FPGA 视频采集和显示项目,这份工程代码值得下载下来改成自己的一套调试习惯。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进