ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

FPGA实现Bayer转RGB与1080p相机图像流水线

FPGA实现Bayer转RGB与1080p相机图像流水线

简介:面向FPGA图像处理与高清摄像头系统开发者的全高清相机工程,重点涉及Bayer到RGB的图像链路与3A性能支持,适合具备一定FPGA基础、希望理解相机控制与图像处理协同设计的中高级读者。工程描述提到,当前rev.3硬件仍需MCU工程完成参数配置与无线控制,而计划交付的rev.4版本可由MCU与FPGA共同承担相关功能,便于对照两代架构理解模块划分与接口协作。压缩包整体约33.42MB,资源页未给出文件总数与主要类型明细,具体以包内工程结构为准。该资源已有201人学习,价值在于提供全高清相机项目的硬件版本演进思路、3A功能落点以及Bayer/RGB处理链路的工程参考,适合用于方案调研、课程设计或项目原型搭建时查漏补缺。

1. 从传感器输出到显示器:先看懂这屏满屏绿色马赛克

调试间里最常见的一幕:1080p 传感器已经能在示波器上输出干净的像素时钟,FPGA 也把数据抓进了 Block RAM,可显示器上就是一屏满屏的绿色马赛克。这就是 full-HD-camera 这类工程要解决的第一道难题——传感器输出的是 Bayer 格式原始数据,每个像素只有 R、G、B 三个通道里的一个分量,人眼和显示器都不接受,得由 FPGA 在像素数据流不停顿的前提下还原成 RGB888,再经过帧缓存送出显示。这套工程把传感器配置、Bayer 转 RGB、白平衡与 Gamma、DDR3 乒乓缓存、1080p 时序输出串成了一条完整链路,适合已经能写 Verilog、想真正做出一路能看的相机图像的开发者,也适合拿它当图像流水线练手的人。

2. 系统架构:一条 1080p60 像素流要经过哪几级

在写第一行 RTL 之前,先把整条链路的各级划分、时钟域和数据位宽确定下来,后面 80% 的返工都能避免。这个项目的结构并不复杂,复杂的是各级之间的衔接:传感器出来的像素是“跟随 de 有效”的流,DDR 那边是“按地址突发读写”的存储器接口,HDMI 那边又是“严格按 1650×750 定时”的扫描时序,三种节拍必须用 FIFO 或双口 RAM 隔开。

2.1 模块划分:五级流水,每级只做一件事

按数据流方向拆,工程里大致是这几个模块,边界尽量按“输入什么、输出什么”来定,别按“用什么算法”来定,不然后面换算法要牵连一大片。

模块输入输出时钟域
cfg_i2c寄存器配置表传感器寄存器写入配置时钟
rx_phyLVDS/DVP 差分或并行数据12bit Bayer 像素流 + de/hs/vspclk(像素时钟)
isp_bayerBayer 像素流12bit 预处理后 Bayerpclk
demosaic3×3 窗口24bit RGB888pclk
isp_rgbRGB888RGB888(白平衡/CCM/Gamma 后)pclk
frame_bufRGB888 流按显示时序读出的 RGB888ui_clk 与 hdmi_clk
hdmi_tx行场同步 + 像素TMDS 或并行 RGBhdmi_clk

一级流水只干一件事,好处是每级都能单独仿真、单独上板验证。我一般会先把 rx_phy 的输出接到一个测试图案发生器上,让后面整条链路先跑通,再去接真的传感器——顺序反过来,出了问题根本分不清是传感器没配对还是 ISP 算错了。

时钟域一共三个:传感器送来的 pclk(1080p60 下常见 74.25MHz 或 148.5MHz,看是单沿还是双沿采样)、DDR 控制器给的用户时钟 ui_clk、以及 HDMI 输出的像素时钟 hdmi_clk。三者之间必须用异步 FIFO 或者乒乓缓冲隔开,不能靠“打两拍”糊过去,多 bit 数据直接打两拍是典型的翻车现场。

2.2 带宽与缓存的账:为什么片内 BRAM 一定不够

这一步很多人跳过,直接上板,结果发现帧缓存写不进去。先把数算清楚:

// 1080p60 的带宽与缓存容量估算,参数直接决定后面选什么存储器 localparam H_ACTIVE = 1920; localparam V_ACTIVE = 1080; localparam FPS = 60; localparam PIX_CLK = 148_500_000; // 1080p60 像素时钟,单位 Hz localparam BAYER_BW = 12; // 传感器原始位宽,10/12bit 都常见 localparam RGB_BW = 24; // 内部统一用 RGB888,省得后面再转 // 每秒像素数:约 124.4M pixel/s localparam PIX_RATE = H_ACTIVE * V_ACTIVE * FPS; // Bayer 域带宽:124.4M * 12bit ≈ 1.49 Gbps localparam BW_BAYER = PIX_RATE * BAYER_BW; // RGB 域带宽:124.4M * 24bit ≈ 2.99 Gbps localparam BW_RGB = PIX_RATE * RGB_BW; // 单帧容量:Bayer 约 3.11MB,RGB888 约 6.22MB localparam FRAME_BAYER = H_ACTIVE * V_ACTIVE * BAYER_BW / 8; localparam FRAME_RGB = H_ACTIVE * V_ACTIVE * RGB_BW / 8;

算完就能得出两个结论。第一,RGB888 单帧 6.22MB,乒乓缓冲要两帧 12.4MB,片内 BRAM 那点容量(中低端器件通常几百 KB 到一两 MB)根本放不下,必须外挂 DDR3 一类的存储器;第二,写侧 2.99Gbps、读侧同样 2.99Gbps,加起来接近 6Gbps,一颗 16bit@400MHz 的 DDR3 理论带宽 1.6GB/s(约 12.8Gbps),看似够用,但实际有效带宽要按 60%~70% 打折,还要留出行刷新和仲裁开销。所以帧缓存这一级一定要做读写分离的乒乓结构,写满一帧再切 bank,别想着“边写边读同一块区域”。

反过来,去马赛克需要的 3×3 窗口倒是很省:两行 1920×12bit 的行缓存约 5.6KB,用两个 BRAM 就够,不需要出去绕一圈。区分清楚“哪些数据只需要行级缓存、哪些必须帧级缓存”,是这一级设计的核心。

2.3 跨时钟域的三种正确做法

异步 FIFO 负责像素流跨域,这是最常用的;帧同步信号(vsync)跨域要用脉冲同步器加握手,不能直接两级触发器;多 bit 的配置寄存器从配置时钟域进到 pclk 域,要么用格雷码,要么在使能信号同步后再采样数据总线。这三种手段对应三种场景,选错了不会立刻出错,但会在某个温度或者某个批次上偶发花屏,非常难查。

3. Bayer 转 RGB 的核心实现:3×3 窗口与双线性插值

去马赛克(demosaic)是这个项目的心脏。RGGB 排列下,每个 2×2 单元里有一个 R、一个 B、两个 G,G 的数量是另外两个的两倍,正好对应人眼对绿色更敏感的特性。还原的思路就是:对每个像素,它已有的通道直接用,缺的两个通道用邻域加权估出来。双线性插值是工程上最常用的起点,资源少、延迟固定、效果对大多数场景够用。

3.1 用两级行缓存搭出 3×3 滑窗

插值需要当前像素的上下左右和对角邻居,所以必须同时看到三行。做法是两片行缓存加行内移位寄存器:

// 两级行缓存 + 行内移位,凑出 3x3 邻域 // 命名沿用常见约定:p11 左上、p22 中心、p33 右下 // 行缓存深度取 2048(>1920),留出行消隐期间的余量 wire [11:0] line1, line2; line_buffer #(.DW(12), .DEPTH(2048)) u_lb0 ( .clk(pclk), .we(de), .waddr(hcnt[10:0]), .din(pix_in), .re(de), .raddr(hcnt[10:0]), .dout(line1) // 上一行同一列 ); line_buffer #(.DW(12), .DEPTH(2048)) u_lb1 ( .clk(pclk), .we(de), .waddr(hcnt[10:0]), .din(line1), .re(de), .raddr(hcnt[10:0]), .dout(line2) // 上上行同一列 ); always @(posedge pclk) begin if (de) begin p13 <= p12; p12 <= p11; p11 <= pix_in; // 第 3 行:当前行 p23 <= p22; p22 <= p21; p21 <= line1; // 第 2 行:上一行 p33 <= p32; p32 <= p31; p31 <= line2; // 第 1 行:上上行 end end

这里有两个参数要解释清楚。行缓存深度取 2048 而不是 1920,是为了让写地址和读地址在行消隐期间也能自增,不会因为 de 拉低而卡住指针;读地址和写地址用同一个 hcnt,相当于读出的正好是一行之前的同一列。位宽 12bit 是跟着传感器走的,如果传感器输出 10bit,这里就改成 10bit,不要图省事统一扩到 16bit,BRAM 会多吃一倍。

关键一点:输出的中心像素 p22 相对输入 pix_in 延迟了“一行加一个像素”。这意味着后面做相位判断时,用的行号和列号也必须同步延迟,否则 RGGB 的判断会整体错一位,画面就会出现那种“绿一块紫一块”的诡异颜色。这个坑我在第五章单独讲。

3.2 双线性插值的四路分支

按中心像素所处的相位分四种情况算。RGGB 下,偶数行偶数列是 R、偶数行奇数列是 G、奇数行偶数列是 G、奇数行奇数列是 B。

// 相位判定必须用延迟后的坐标,和 p22 对齐 reg [10:0] hcnt_d, vcnt_d; always @(posedge pclk) begin hcnt_d <= hcnt; vcnt_d <= vcnt; end wire p_col = hcnt_d[0]; wire p_row = vcnt_d[0]; // 组合逻辑实现双线性插值,除法用移位代替 reg [13:0] g_sum, b_sum; reg [11:0] r_out, g_out, b_out; always @(*) begin case ({p_row, p_col}) 2'b00: begin // R 位置:已有 R,补 G 和 B g_sum = p12 + p21 + p23 + p32; // 上下左右四个 G b_sum = p11 + p13 + p31 + p33; // 四个对角 B r_out = p22; g_out = g_sum[13:2]; // /4 b_out = b_sum[13:2]; end 2'b11: begin // B 位置:已有 B,补 G 和 R g_sum = p12 + p21 + p23 + p32; b_sum = p11 + p13 + p31 + p33; b_out = p22; g_out = g_sum[13:2]; r_out = b_sum[13:2]; end default: begin // G 位置:R 和 B 各取水平或垂直邻居 r_out = (p_row == 1'b0) ? (p21 + p23) >> 1 : (p12 + p32) >> 1; b_out = (p_row == 1'b0) ? (p12 + p32) >> 1 : (p21 + p23) >> 1; g_out = p22; end endcase end

参数和位宽是这段代码最容易出错的地方。四个 12bit 相加最大 16384,需要 14bit 才装得下,所以g_sum声明成 14 位,取[13:2]相当于右移两位完成除以 4。如果偷懒把和存回 12bit,高光区域就会溢出回绕,亮部变成紫绿色——这是最典型的溢出 bug,仿真小图看不出来,一上真场景就暴露。G 位置那两个>>1之所以不写成除法,是因为综合器对常数移位处理得更好,而且不会引入除法器 IP。

3.3 白平衡、颜色校正与 Gamma 的插入点

这三个模块放的位置有讲究,放错了要么效果差,要么资源翻倍。

白平衡放在去马赛克之前,在 Bayer 域做。原因是此时 R、G、B 分量还是天然分开的,只需要按相位给三个不同的增益,三个乘法器就搞定;放到 RGB 域做则要对三个通道各乘一次,虽然也是三个乘法器,但此时数据已经插值过,错误的增益会被扩散到邻近像素上。

// Bayer 域白平衡:Q4.8 定点增益,乘完右移 8 位还原 // 注意要带饱和截断,不能直接截断低位 wire [1:0] phase = {p_row, p_col}; wire [11:0] wb_gain = (phase == 2'b00) ? GAIN_R : (phase == 2'b11) ? GAIN_B : GAIN_G; wire [23:0] wb_mult = pix_pre * wb_gain; wire [11:0] wb_out = (wb_mult[23:8] > 12'hFFF) ? 12'hFFF : wb_mult[23:8];

颜色校正矩阵(CCM)是 3×3,九个系数,硬算要九个乘法器,中低端器件上会明显吃紧,常见做法是三个 DSP 分时复用,用三倍时钟跑完一行矩阵运算。Gamma 用一张 12bit 输入、8bit 输出的查找表,深度 4096,放 BRAM 里正好,同时把位宽从 12bit 压到 8bit,后面帧缓存的带宽直接省三分之一。

整条 ISP 的顺序定死为:黑电平校正 → 坏点/降噪(可选)→ 白平衡 → 去马赛克 → CCM → Gamma → RGB888 输出。这个顺序不是随便排的,黑电平必须最前(它影响所有后续统计),Gamma 必须最后(它破坏线性关系),中间两步换顺序影响不大但别来回调。

4. 工程落地:仿真、约束与上板 bring-up

RTL 写完只是开始,能不能出图取决于验证和时序这两块。这一章讲的是怎么把“仿真里对”变成“板子上对”。

4.1 用参考模型做逐像素比对

图像算法最怕“看起来差不多”,人眼对 1~2 个 LSB 的差异根本不敏感,但错误的插值会在边缘产生彩色条纹。稳妥的做法是写一个 Python 参考模型,输入同一张 Bayer 图,输出逐像素的 RGB,再和 Verilog testbench 的输出做逐字节比对。

import numpy as np def demosaic_rggb(bayer): """双线性插值参考模型:输入 HxW 的 Bayer(RGGB),输出 HxWx3 的 RGB""" b = bayer.astype(np.int32) h, w = b.shape # 用 roll 做整体平移,逻辑和 RTL 的四邻/对角取法一一对应 up, down = np.roll(b, -1, 0), np.roll(b, 1, 0) left, right = np.roll(b, -1, 1), np.roll(b, 1, 1) g_avg = (up + down + left + right) // 4 # 上下左右均值 d_avg = (np.roll(np.roll(b, -1, 0), -1, 1) + np.roll(np.roll(b, -1, 0), 1, 1) + np.roll(np.roll(b, 1, 0), -1, 1) + np.roll(np.roll(b, 1, 0), 1, 1)) // 4 # 四对角均值 rgb = np.zeros((h, w, 3), dtype=np.int32) rgb[0::2, 0::2, 0] = b[0::2, 0::2] # R 位置:R 原值 rgb[0::2, 0::2, 1] = g_avg[0::2, 0::2] rgb[0::2, 0::2, 2] = d_avg[0::2, 0::2] rgb[1::2, 1::2, 2] = b[1::2, 1::2] # B 位置:B 原值 rgb[1::2, 1::2, 1] = g_avg[1::2, 1::2] rgb[1::2, 1::2, 0] = d_avg[1::2, 1::2] rgb[1::2, 0::2, 1] = b[1::2, 0::2] # G 位置:按行相位取水平/垂直邻居 rgb[1::2, 0::2, 0] = (left + right)[1::2, 0::2] // 2 rgb[1::2, 0::2, 2] = (up + down)[1::2, 0::2] // 2 rgb[0::2, 1::2, 1] = b[0::2, 1::2] rgb[0::2, 1::2, 0] = (up + down)[0::2, 1::2] // 2 rgb[0::2, 1::2, 2] = (left + right)[0::2, 1::2] // 2 return np.clip(rgb, 0, 4095).astype(np.uint16)

这段模型有个陷阱要提前说明:np.roll是循环移位,图像最外一圈像素会从对面绕回来,和 RTL 里“边界保持原值”或者“边界复制”的行为对不上。所以做比对时要么裁掉最外一圈像素,要么在模型里手动 padding。我第一版就是没处理这个,比对报了 2.3% 的失配,全部集中在边缘,白查了两天。

testbench 那边,输入用$readmemh读一张 Bayer 的 hex,输出用$fwrite写成十进制文本,Python 脚本读两边做差,打印最大误差所在的行列。误差应该严格为 0(定点算法完全一致),如果不是 0,基本就是位宽或者相位的问题。

4.2 时序约束:三个时钟和它们之间的边界

约束写不对,综合出来的电路时序再难也白搭。三个主时钟各自声明周期,跨域的路径交给异步 FIFO 自己处理:

# 三个主时钟:传感器像素时钟、DDR 用户时钟、HDMI 像素时钟 create_clock -name pclk -period 13.468 [get_ports pclk] # 约 74.25MHz create_clock -name hdmi_clk -period 6.734 [get_ports hdmi_clk] # 约 148.5MHz create_clock -name ui_clk -period 5.000 [get_ports ui_clk] # 200MHz # 三个域之间没有相位关系,声明为异步,让工具别去凑 set_clock_groups -asynchronous \ -group {pclk} -group {hdmi_clk} -group {ui_clk} # 输入输出延迟:DDR3 那侧由控制器 IP 自带约束,这里只管传感器接口 set_input_delay -clock pclk 2.0 [get_ports {sensor_data[*]}] set_output_delay -clock hdmi_clk 1.5 [get_ports {hdmi_data[*]}]

set_clock_groups -asynchronous这句话很有迷惑性:它只是告诉工具“这几条路径不用按时序收敛去优化”,并不代表电路可以随便跨。真正的跨域电路还是得老老实实做同步器或者异步 FIFO,约束只是让工具别把资源浪费在根本做不到的路径上。反过来,如果跨域结构本身是错的,加了这条约束之后时序报告会变得很干净,人就被骗过去了。

另外,isd 那一行里的sensor_data[*]这类总线端口,如果实际是差分对,得先用 IBUFDS 转成单端再约束,别直接约束在差分 pad 上。这个细节各家的原语名字不同,按所用器件的手册来。

4.3 上板 bring-up 的检查顺序

板子第一次通电,别急着接传感器。我一般按这个顺序走,每一级都有明确的“通过标准”:

  1. 时钟检查。用示波器或者逻辑分析仪确认 pclk、hdmi_clk、ui_clk 三路时钟频率和预期一致,PL 侧的锁定信号拉高。频率差一点点(比如 74.25 变成 74.17)通常是时钟芯片配置寄存器写错。
  2. 配置通路检查。通过 I2C 从传感器读回一个已知的 ID 寄存器,能读回来再谈后面的,读不回来先查上拉和地址位。
  3. 数据流检查。把 de 有效期间的像素计数接到一个计数器上,看是不是每帧 1920×1080。数目对不上,查行场同步的极性。
  4. 测试图案直通。用一个内部产生的彩条替换传感器数据,走完整条链路到 HDMI,显示器能出图就说明帧缓存和输出时序没问题。
  5. 接真传感器,先关掉去马赛克,直接把 Bayer 数据按灰度显示出来。这时候应该看到一张带网格纹理的灰度图,能看到说明采集正常。
  6. 打开去马赛克,看颜色。

这一步最有用的工具是片内的逻辑分析仪 IP:把 p11~p33 九个像素值、相位信号、de 全部接上去,触发条件设成“相位为 R 位置且 p22 大于某个阈值”,抓一帧看看窗口对不对。这种“黑匣子”式的观察,比在仿真里瞎猜快十倍。

5. 避坑与排查:五个让我返工的坑

下面这几条都是实际返工过的,按“现象 → 原因 → 解决”记下来,遇到同类问题可以直接对号入座。

坑一:整幅画面偏绿,白色物体发青。

现象是拍灰卡或者白纸,RGB 三个通道的均值明显不相等,G 比 R、B 高出一截。原因是白平衡增益只写了 R 和 B 两路,G 那一路忘了设成 1.0 单位增益,或者相位判断用的是没有延迟的 hcnt,导致增益打到了错误的像素上。解决办法是先输出一张纯色图,统计三通道均值,反推出应有的增益比,再回头核对phase信号是否和 p22 对齐。

坑二:图像边缘出现 1 像素宽的彩色拉丝,竖直方向尤其明显。

现象是黑白交界处有一圈紫边或绿边。原因通常是行缓存的读写地址差了半行,或者 de 下降沿没有清空 3×3 窗口寄存器,导致行首的前两个像素用到了上一行末尾的残值。解决是在 de 拉低时把 p11~p33 全部清零,同时用一张黑白棋盘格做测试图——棋盘格的每个角都是相位边界,一旦错位一眼就能看出来。

坑三:高光区域颜色反转,亮的灯变成紫绿色。

现象只在过曝区域出现。原因是插值累加时位宽不够,四个 12bit 数相加溢出回绕。解决是把累加寄存器扩到 14bit 以上,移位之后再做一个饱和判断,超过全 1 就钳位到全 1。这个坑在仿真小图里几乎发现不了,因为测试图很少有大面积高光。

坑四:画面上下半帧不一致,像撕裂一样。

现象是屏幕上半部分和下半部分来自不同的帧。原因是乒乓缓冲的 bank 切换和显示端的 vsync 没有对齐,读侧读到了一块正在写的区域。解决办法是写侧写满一帧后只置起一个切换请求信号,这个信号跨到读时钟域用脉冲同步器打两拍,读侧在下一个 vsync 到来时才真正切 bank——切换时刻必须落在消隐期。

坑五:功能仿真全对,上板偶发花屏,时序报告却显示收敛。

现象是跑几分钟出一次错,重启又好了。原因是跨时钟域传了多 bit 数据却只用两级触发器同步,各 bit 到达时间不同,采到的组合是个不存在的值。解决是多 bit 一律走异步 FIFO 或格雷码,单 bit 控制信号才用两级同步器;另外,先确认结构正确再改约束,顺序反了会掩盖问题。

6. 二次开发:把固定管线改成可配置的 ISP 小框架

把这套代码跑通之后,大多数人下一步都会想把参数调起来——增益凭什么写死?CCM 系数换个镜头就不对了。我的建议是别在老代码上直接改魔数,而是先加一层寄存器配置空间,把 ISP 变成可配置的。

具体做法是挂一个轻量的寄存器总线(选 AXI-Lite 或者自己写一个简单的 SPI 配置口都行),给每个可调项分配地址。关键的几项大概是这些:

配置项偏移位宽说明
GAIN_R0x1012Q4.8 定点,0x100 表示 1.0
GAIN_G0x1412双 G 通道共用,省一个寄存器
GAIN_B0x1812同上
CCM_00~220x20~0x409×12Q2.10 定点,含负数补码
GAMMA_LUT0x100~4096×8用 BRAM 初始化,运行期可写
DEMO_MODE0x0440 正常,1 灰度直通,2 彩条

加完这层,调白平衡就不用重新综合了,上位机点一下就能看到效果。再往前一步,可以把去马赛克从双线性换成梯度加权(Hamilton-Adams 那类),在边缘区域的拉链效应会明显改善,代价是多几个行缓存和一堆绝对值运算——在中低端器件上要掂量 DSP 和 BRAM 的余量,我一般先跑一遍资源报告再决定。

还有一个很实用的改造点:把整条管线的接口统一成 AXI-Stream 风格的 valid/ready 握手。现在的代码是纯 valid 链,上游一停下游就得跟着停,接了 DMA 或者软核之后就很难配合。改成 valid/ready 之后,帧缓存那一级可以做反压,传感器数据不会因为 DDR 偶尔的仲裁延迟而丢失。

验证改造是否成功的方法很朴素:准备三组测试图(灰卡、棋盘格、带大面积高光的实拍图),每组跑一遍,把输出图和参考模型的差值统计出来。改造前后的最大误差应该完全一致,只要差一个 LSB,就说明某处的定点格式悄悄变了。

最后说个血泪经验:我现在的习惯是,任何一次改动只要碰了位宽或者相位判断,都必须把 Python 参考模型重跑一遍,不许“看着没问题就过了”。因为这个模块的 bug 从来不会在功能仿真里报错,它只会安安静静地把颜色算错一点点,等到上板看到画面不对,前面几天的调试记录全都白做了。把这套通过标准固定成流程,比事后补救省事得多。希望帮到你。

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

返回列表