1. 为什么非要在FPGA里搭一套ISP流水线
1.1 Sensor吐出来的数据,根本不是一张“照片”
我第一次拿到OV5640的RAW输出时,一度以为是板子坏了。整幅画面灰蒙蒙的,仔细放大能看到密密麻麻的彩色颗粒,完全不是相机屏幕上的样子。
这个现象一点都不意外。CMOS sensor为了兼顾成本和灵敏度,每个像素上只镀一种滤色片,最常见的是Bayer排列:2x2的像素块里R、G、R、B各占一个位置。换句话说,sensor直接输出的Bayer RAW里面,每个像素只有R/G/B三通道中的某一个通道信息,另外两个通道都是缺失的。要让画面变成人眼认可的彩色照片,必须靠ISP(Image Signal Processor)把缺失的颜色“猜”出来,再做白平衡、颜色校正、Gamma等一系列处理。
所以,ISP不是锦上添花的后期滤镜,而是从RAW到可视图像之间绕不开的一环。手机拍照、车载摄像头、安防监控、工业相机,甚至医疗内窥镜,sensor后面必然跟着一条ISP链路,差别只是跑在专用芯片上、跑在软件里,还是跑在FPGA上。
1.2 为什么不用软件、也不用专用ISP芯片,偏要用FPGA
决定用FPGA做这个项目之前,我先认真比较了三条路线。这里说的“为什么”,是每个想做FPGA图像处理的人必须先想清楚的,否则到后面很容易做一半犹豫。
| 方案 | 延迟 | 灵活性 | 开发成本 | 最典型场景 |
|---|---|---|---|---|
| 纯软件(Python/OpenCV/Matlab) | 帧级延迟,毫秒到几十毫秒 | 极高,改算法很快 | 低,入门容易 | 算法原型验证、离线处理 |
| 专用ISP芯片 | 固定,通常几毫秒内 | 低,只能调寄存器不改硬件 | 中等,画质调优很费人力 | 手机、相机等量产设备 |
| FPGA上的ISP | 行级延迟,几十到几百微秒 | 高,模块自己写、随时改 | 高,调试难度大 | 工业视觉、车载、多路实时处理 |
我用FPGA,核心原因就一句话:延迟要确定,吞吐要够高。
软件ISP非常灵活,但延迟是不确定的。操作系统调度、缺页中断、DMA排队都会让处理时间抖动。工业相机触发拍照的场景里,sensor出图到结果返回必须稳定在几个行周期内,软件很难给出这种确定性。FPGA则不同,像素流像水一样流过各级流水线,每一级延迟是固定的,说多少拍就是多少拍。
还有一路多摄的场景。一块FPGA可以同时接四个摄像头,每个摄像头各自跑一条ISP链路,总吞吐可以到几十Gbps。这个并行度,任何一颗通用处理器都很难轻松做到。
当然,FPGA的缺点是开发周期长、调试繁琐,后面几章你会看到我踩了多少坑。但如果你追求的是实时、低延迟、可定制,FPGA确实是值得投入的方向。
1.3 我定的项目边界:先跑通最小可用链路
“从零搭建ISP流水线”听起来范围很大。真做的时候如果什么都想上,大概率会在某一个模块里陷进去出不来。我给自己定的第一阶段目标是:在Xilinx FPGA上,用Vivado开发,把Bayer RAW(10/12bit)输入变成标准RGB888输出,分辨率1080p30,主时钟按148.5MHz跑,功能完整、时序收敛。
第一阶段模块顺序固定为:
Bayer RAW -> 坏点矫正DPC -> 黑电平BLC -> 白平衡AWB -> 去马赛克Demosaic -> 颜色校正CCM -> Gamma -> RGB888
镜头阴影校正LSC、降噪、锐化、3A统计这些全部放到第二阶段再加。这样分阶段的道理很简单:上面这条链路是“从RAW到能看”的最小闭环,只要跑通一次,整个数据流、行缓存、同步信号、验证流程就全部打通了,后面加模块只是往流水线上挂新的处理级,不会伤筋动骨。
这条链路跑通以后,你会获得一个特别重要但没法量化的东西:对像素流的感觉。有了这种感觉,再回头写LSC、降噪这些算法,思路会完全不一样。
2. 先把数据流和模块边界画清楚
2.1 ISP模块顺序不是死的,但主线是稳定的
在开始写任何Verilog之前,我花了整整两天画数据流图。这步不能省,因为ISP各个模块的先后顺序在不同方案里是有差异的,而且每个模块输入的“域”不一样,一旦顺序错了,颜色完全没法看。
我最终采用的主线如下。
| 模块 | 输入 | 我的顺序 | 为什么放在这里 |
|---|---|---|---|
| 坏点矫正DPC | Bayer RAW | 最前面 | 坏点是sensor自身的固定缺陷,必须最先处理,否则后面滤波会把它扩散到周围像素 |
| 黑电平BLC | Bayer RAW | 第二个 | 把无光时的偏置减到0,后续乘增益才不会把偏置一起放大 |
| 白平衡AWB | Bayer RAW | 去马赛克之前 | 在RAW域直接对R/B通道乘增益,只涉及两个通道,比RGB域简单,也不会引入颜色串扰 |
| 去马赛克Demosaic | Bayer RAW | 白平衡之后 | 把单通道数据还原成完整RGB |
| 颜色校正CCM | RGB | 去马赛克之后 | 3x3矩阵运算,必须在RGB域做 |
| Gamma | RGB | 最后 | 适配显示设备非线性,顺便把高位宽压缩到8bit |
有一个细节值得先说:白平衡放去马赛克之前还是之后,业界一直有不同做法。我在这里把AWB放在RAW域,纯粹是工程上省事——这样只需要对R/B两个通道各做一次乘法,G通道等于没有运算。放在RGB域做,三个通道都要做,乘法器资源直接×1.5。
2.2 用DE、HSYNC、VSYNC把模块串起来,别搞丢同步
FPGA里的图像数据和CPU里不一样,没有“坐标”这个概念,全靠同步信号来标识位置。像素时钟的每个有效沿,data总线上有一个像素,DE(数据有效)拉高代表这个像素在有效区域内;HSYNC拉高代表一行开始;VSYNC拉高代表一帧开始。
我用Xilinx AXI4-Stream接口作为模块之间的统一握手协议:TVALID表示当前有数据,TREADY表示下游可以接收,TLAST标志行尾,TUSER标志帧首。每个模块的输入输出都保持这套接口,模块之间像水管一样串起来,方便单独debug。
这里有个非常容易踩的坑:模块内部处理有延迟,但同步信号没有跟着一起打拍。比如去马赛克内部做了8拍延迟,HSYNC、VSYNC如果没有同样延迟8拍,帧起始位置就偏了,画面会撕裂、斜切。我自己在这个问题上浪费了整整一个晚上,第7章会详细复盘。
所以从第一天起就要立一个规矩:所有与像素数据并行的控制信号,数据打多少拍,控制信号就打多少拍。要么把同步信号一起寄存,要么用FIFO把整组信号同步到新的像素时钟域。这条规矩写进自己的代码规范里,后面会省非常多的调试时间。
2.3 没有摄像头也能起跑:先上一个测试图源
接摄像头上板是ISP项目里最容易让人崩溃的阶段:MIPI物理层抖动、lane分配错、分辨率不匹配、协议解析出错,一个问题叠着另一个,你根本分不清是sensor的问题还是自己ISP的问题。
我的做法是:先把摄像头完全踢出链路,用测试图源代替真实输入,等ISP主链路全部调通了再接摄像头。具体有两种测试图源。
第一种,直接用Vivado里的Video Test Pattern Generator IP,生成彩条、渐变、运动图案,走AXI4-Stream接口。优点是省事,缺点是测试图案太“干净”,没有sensor特有的坏点、噪声、偏色,很多ISP问题暴露不出来。
第二种,把一张真实的Bayer RAW图转成COE文件,用BRAM按像素时序读出来,模拟sensor输出。这个更接近真实场景。我用Python把一段真实RAW数据预处理成12bit的RGGB序列,按1920x1080排好,存成hex文件,上板时通过$readmemh初始化到BRAM里。这样等于把一个“数字孪生”的sensor放进FPGA,摄像头的问题一个都还没遇到,但ISP全链路已经跑起来了。
这一步强烈建议做。它把“FPGA图像处理”和“摄像头驱动”两个问题彻底解耦了,你不需要在写ISP的同时还要对付sensor寄存器配置。
3. 行缓存Line Buffer:FPGA图像处理绕不开的第一道坎
3.1 为什么邻域运算在FPGA里必须用行缓存
去马赛克、坏点检测、降噪、边缘增强,这些算法都需要读取当前像素周围3x3或5x5的邻居。在CPU里,Bayer图就是一块连续内存,按坐标随机访问天经地义。但FPGA接收sensor数据是逐行流式输入的:像素到了必须立刻处理,没有“回顾整帧”的随机访问能力。
如果你把整帧写进DDR再读出来做卷积,带宽要翻好几倍,延迟也从行级变成帧级,FPGA相对软件的核心优势瞬间荡然无存。
工业界标准方案是Line Buffer:把最近几行数据缓存下来,配合当前输入,组合出一个滑动窗口。以3x3窗口为例,只需要缓存前两行,当前行进入后,每个时钟都能输出一个以当前像素为中心的3x3邻域。原理其实很简单:
- 当前行数据进一组移位寄存器,保留最近3个像素;
- 前一行从Line Buffer1按列读出,再进一组移位寄存器;
- 前两行从Line Buffer2读出,同样进一组移位寄存器。
这样每来一个像素,三个寄存器的输出就凑成一个完整的3x3窗口。窗口中心和“最新进入的像素”之间会有大概一行加一列的延迟,但这不重要,只要所有模块的延迟一致,整条流水线就像一条加了固定延迟的管道,数据还是连续流。
3.2 FIFO还是BRAM:建议直接用双端口RAM包移位寄存器
实现Line Buffer有两个选择。一种是直接例化Xilinx FIFO IP,简单,但FIFO只能顺序读,没法在同一拍读出两个不同行的任意列数据。当你同时需要“上一行第X列”和“上上行第X列”时,要两个FIFO配合,通是能通,代码却很别扭,以后再升级到5x5、7x7滤波会更麻烦。
另一种方案,也是我用下来比较顺的:用BRAM自己包一个移位寄存器。
在Vivado里可以例化一个简单的双端口RAM,一个端口写,另一个端口读。写地址是当前列计数,读地址比写地址延迟固定行数,这样每拍读出的就是上一行对应列的数据。再加上移位寄存器链,代码结构非常清晰:
// 行缓存读地址控制:比写地址晚一个完整行周期 reg [10:0] wr_addr; reg [10:0] rd_addr; always @(posedge clk) begin wr_addr <= h_cnt; rd_addr <= (h_cnt == H_ACTIVE - 1) ? 0 : h_cnt + 1; // 读比写快一行 end // 上一行、上上行像素进入移位寄存器 always @(posedge clk) begin line1_d1 <= line1_rd_data; // 上一行第X列 line1_d2 <= line1_d1; // 上一行第X+1列 line1_d3 <= line1_d2; // 上一行第X+2列 line2_d1 <= line2_rd_data; line2_d2 <= line2_d1; line2_d3 <= line2_d2; cur_d1 <= cur_pixel; cur_d2 <= cur_d1; end这里特别提醒一下:BRAM读数据是有延迟的,一般1到2拍,写地址和读地址的关系必须把延迟算进去。我在第一次写的时候没算BRAM读延迟,结果窗口里的“上一行”实际是“上前两行”,整个卷积核全部错位,画面出现斜向拖影。调这个问题时我一度怀疑算法写错了,最后才发现是行缓存的时序没对齐。
3.3 边界像素处理:宁可丢边,不要花屏
3x3窗口在最左边、最右边、最上面一行、最下面一行是没有完整邻居的。教科书上有补零、镜像、复制各种处理,工程上我强烈建议第一阶段先直接丢弃这些边界像素。
原因很现实:边界几个像素丢了,肉眼完全看不出差别;但补边逻辑如果时序没对好,整个画面的同步信号都会错位,问题定位成本远超收益。
实现上很简单:窗口建立起来后,重新生成一个valid信号,把前N行、前N列、后N列都拉低,模块内部不做任何坐标判断,只在输出端把数据mask掉。这样既能避免卷积核凑不齐的情况,又不会给状态机引入额外复杂度。
还有一个和边界同样隐蔽的问题:BRAM上电内容是随机的。如果复位后第一帧直接读行缓存,前两行会读到随机数据参与卷积,表现就是画面顶部出现一条彩色的噪声带。解决方法是帧同步到来时把行缓存清零,或者在前几行窗口未满时强制输出全0且valid拉低。两种都行,但我更推荐后者,因为不需要额外复位逻辑,只要在valid生成时处理一下。
4. 核心模块实现顺序:从RAW一路做到彩色
4.1 坏点矫正和黑电平:先把数据拉回“正常范围”
坏点(Dead Pixel)是sensor制造时就有或者使用中逐渐产生的缺陷点,表现为固定位置全黑或全白。坏点矫正的思路很直白:用当前像素和周围同色通道的像素比较,如果当前值和周围值差异超过阈值,就用邻域均值替代。硬件上就是前面搭好的3x3窗口,对同色通道做均值,再加一个比较器、一个选择器。
// 坏点检测:当前像素与周围同色像素的差超过阈值则替换 always @(posedge clk) begin if (abs_diff > defect_threshold) pixel_out <= neighbor_avg; else pixel_out <= pixel_in; end阈值建议做成寄存器,通过VIO在线调。不同sensor的坏点严重程度差异很大,固定阈值往往调不好,有时候现场一看画面有雪花点,VIO把阈值从64调到192,立竿见影。
黑电平(Black Level)则是sensor在完全没有光的条件下,ADC输出的基础偏置,常见值有64、1024、2048。这个偏置必须减掉,否则后面做白平衡、CCM时,所有乘增益都会把偏置一起放大,画面发灰、阴影部分偏色。实现就一个减法加clamp:
data_out = (data_in >= blc_value) ? (data_in - blc_value) : 0;注意减法一定要做clamp,不能让结果下溢成负数。在Verilog里就是判断一下data_in是否小于blc_value,小于则直接输出0。这个判断别省,省了会让后续模块处理到负值时出现各种奇怪的暗部噪声。
4.2 镜头阴影校正:用增益表把暗角拉平
镜头边缘进光量比中心少,画面四角会发暗,这就是镜头阴影(Lens Shading)。镜头阴影校正(LSC)的做法是在RAW域乘一个和坐标相关的增益。中心增益小,边缘增益大,乘完以后整幅画面的亮度就均匀了。
实现上有两类方案。一类是grid-based查表:sensor厂商标定一组网格点上的增益,比如横竖每64个像素取一个点,FPGA根据当前像素坐标在网格里做双线性插值,得到当前像素的增益。这个方案精度高,但每像素要做4次乘法和3次加法,DSP占用稍多。
如果第一版不想占那么多DSP,有个更快的办法:直接用整幅图的增益查表。把每像素增益预先算好,存进BRAM,然后用行列地址查表。缺点是BRAM用量大,1080p全图增益表存16bit浮点是可观的,不划算。
我的实际路线是:第一版先不做LSC,把链路跑通;第二版用最简单的水平+垂直一维查表(就是只对暗角做径向近似),验证它能改善暗角;第三版再换成grid双线性插值。每一步只引入一个变量,出了问题才能快速定位。
4.3 去马赛克:整个ISP里最值得花时间调的模块
去马赛克要给每个只有单色信息的像素“猜”出另外两个通道。最简单的双线性插值逻辑:当前像素是R或B时,G通道取上下左右四个邻居的平均;当前像素是G时,R和B分别取左右或上下邻居的平均。
// 以G通道为例:当前R像素,G取上下左右平均 assign g_at_r = (g_up + g_down + g_left + g_right) >> 2;双线性插值的优点是Verilog好写,一个加法树搞定,缺点是斜向边缘会出现明显的彩色条纹,专业术语叫假彩。原因也好理解:边缘处不同方向上的相关性不一样,简单的四个方向等权平均会把颜色信息从边缘一侧“漏”到另一侧。
工程上更常用的是方向性插值,比如Malvar-He-Cutler算法。核心思想是先用水平和垂直两个方向的梯度判断边缘方向,顺着边缘方向插值,横跨边缘方向不插值。梯度计算在FPGA里就是几个加法器加比较器,比双线性多不了多少资源,但画质提升很明显。
我的建议是第一阶段就上双线性,目的不是画质,是把全链路走通。等你在真实sensor图像上看到那些五颜六色的边缘条纹,再花一天换成方向性插值,你会真真切切理解为什么这一步值得投入。直接上方向性插值反而容易和别的bug混在一起,不好排查。
4.4 白平衡和颜色校正矩阵:把色彩拉回“正常认知”
白平衡解决的是不同色温下sensor对同一场景的响应不同。你在白炽灯下拍的纸,不调白平衡一定是黄的不是白的。简单可靠的做法是Gray World算法:假设场景的平均颜色接近灰色,于是统计一帧内R/G/B的均值,令R_gain = G_avg / R_avg,B_gain = G_avg / B_avg,然后把R和B通道乘上对应增益,G通道保持为1。
硬件上要特别注意:统计整帧均值只能在帧末算完,然后下一帧启用新增益。千万别在本帧中间改增益——同一帧上半部分和下半部分用的增益不同,画面上会出现一道明显的亮度分界线。
CCM颜色校正矩阵是因为sensor的RGB响应曲线和人眼视锥细胞不是一套系统。需要用3x3矩阵把相机RGB映射到目标色彩空间。典型形式:
| 输出 | 等于 | 输入 |
|---|---|---|
| R_out | a11R_in + a12G_in + a13*B_in | |
| G_out | a21R_in + a22G_in + a23*B_in | |
| B_out | a31R_in + a32G_in + a33*B_in |
每个输出通道3次乘法加2次加法,一路RGB一共9次乘法和6次加法。这个资源量非常小,但必须用流水线打拍,否则乘法链太长,时序很难收敛。
CCM的系数不能随便从网上抄,它和sensor滤色片的光谱响应强相关。最靠谱的办法是用Python对24色卡做最小二乘标定:拍一张标准色卡,把检测到的RGB和标准RGB做线性回归,解出9个系数。没有标定条件时,先用单位矩阵(a11=a22=a33=1,其他0)跑通链路,后面有精力再标定。
4.5 Gamma与输出位宽:12bit到8bit的最后一公里
RAW域一般是10bit或12bit,经过CCM之后还是12bit甚至14bit,但普通显示器只能显示8bit。Gamma校正一方面适配显示器的非线性显示特性,另一方面也是把高位宽数据压缩到8bit。
硬件实现就一句话:查表。例化一块BRAM做ROM,输入12bit查表,输出8bit。Vivado里Block Memory Generator IP选ROM模式,深度4096,位宽8,初始化文件填Gamma曲线数据就行。
这步有三个坑。第一,查找表地址位宽必须和输入数据位宽一致。如果输入是12bit却只接了低8位到查找表,输出画面在渐变区域会一圈一圈地出断层,这就是banding。第二,Gamma曲线在暗部斜率很大,如果输入位宽只有8bit,暗部会明显丢细节。这也是为什么ISP内部宁可保持12bit处理,最后一步才压缩。第三,查找表输出可以带小数再取整,不然暗部量化误差会放大成可见色块。
输出格式上,如果直接接HDMI屏幕,输出RGB888就行。如果要接后续的视频编码器或者做色彩空间转换,就输出YUV422。RGB转YUV的系数矩阵同样是定点化实现,0.257可以近似成257/1024,误差约0.2%,肉眼看不出差别。
5. 资源与时序:1080p60到底需要多少筹码
5.1 先算账:像素时钟和数据带宽
做任何FPGA图像处理前,第一件事是把时钟和带宽算清楚。1080p60的意思是1920x1080分辨率,每秒60帧。加上行消隐和场消隐,典型的pixel clock是148.5MHz;如果只按有效像素算,大约124.4MHz。
数据带宽:
- RAW域12bit:148.5M × 12bit ≈ 1.78Gbps;
- 输出RGB888:148.5M × 24bit ≈ 3.56Gbps。
这意味着你的数据总线宽度和FIFO深度至少要按这个速率的2倍以上留余量,否则高帧率或分辨率扩展时会丢数据。我当时设计的内部数据总线统一用32bit,12bit RAW右对齐存储,虽然浪费了一点,但留了未来上更高位宽的余量。
5.2 LUT、DSP、BRAM的预算思路
资源预估不用精确到个位,但要有量级感。
以1080p、3x3窗口为例:
| 资源 | 用途 | 预估用量 |
|---|---|---|
| BRAM 36Kb | Line Buffer两行,1920像素×16bit对齐,约2-3块 | 3块左右 |
| BRAM 36Kb | Gamma查找表,4096×8bit | 1块 |
| BRAM 36Kb | 测试图源ROM(如果不用IP) | 若干 |
| DSP48 | CCM矩阵9次乘法 | 9个 |
| DSP48 | LSC双线性插值、白平衡增益乘法 | 10-20个 |
| LUT | 去马赛克方向判断、加法树 | 1-3万左右 |
对于Kintex-7、Artix-7这些常见器件的容量来说,这样一条ISP链路资源非常宽裕。真正紧张的不是BRAM和DSP,而是LUT和布线资源。如果你发现某模块LUT占用超过5万,先别急着加芯片,回头看看状态机是不是写复杂了,或者大量逻辑能不能改用ROM查表。
5.3 流水线打拍与复位策略:两条经验直接抄
经验一:每个模块入口把data和valid打一拍,出口再打一拍。这样不仅方便时序收敛,模块之间串起来后定位问题也容易,因为每个模块的输入输出都有一拍明确的寄存器隔离,不会出现组合逻辑穿过两三个模块的情况。
经验二:用异步复位、同步释放。FPGA内部对异步复位没有额外惩罚,同步释放则能避免复位释放瞬间产生亚稳态。更关键的是,视频链路复位时,sensor的行场信号可能正好处在任意位置。如果复位释放时机不对,第一帧会从任意行开始,画面斜切错位。我的做法是把复位和帧同步绑定:检测到场同步有效后再释放数据路径复位,确保从帧头开始处理。
5.4 时序收敛不过关,先按顺序查这三样
跑了几个小时implementation发现时序不过,先别急着加时序约束,按这个顺序排查。
- 时钟约束有没有配。未约束的时钟Vivado默认按最保守频率跑,或者生成时钟关系不对,各种问题接踵而至。
- 组合逻辑最长的路径是不是在某个大位宽比较器或除法器上。FPGA里千万不要用
/做除法,资源大户且慢。改成移位近似、查表或者Cordic。 - 跨时钟域有没有做同步。ISP链路里sensor输出像素时钟和FPGA内部处理时钟往往是两个域,接MIPI时尤其明显,一定要用异步FIFO或XPM手动同步,不能直接连。
6. 验证才是重头戏:Python参考模型加Testbench
6.1 为什么必须先写Python参考模型
我的习惯是RTL动手之前,先写一版Python(Matlab也行)参考模型。这版模型用同样的Bayer图,实现和打算在FPGA上跑的ISP算法,先在软件里验证算法效果,再把它的输出作为golden reference。
有了golden,RTL里每个模块的输出都可以和golden在固定误差范围内对比。误差主要来自三处:
- 定点化近似误差,比如0.257近似成257/1024,误差约0.2%;
- 边界处理差异,我选择丢弃边界,软件里也必须丢弃同样数量;
- 除法转移位近似,除3在硬件里不好做,常常改成乘341再右移10bit。
这些误差在验证时提前考虑进去,比对才不会天天“误报”。否则每次仿真看到几个像素有差就开始排查,最后发现是参考模型自身没做定点化,白白浪费一天。
6.2 Testbench分三段:激励、DUT、结果比对
Testbench我习惯分三段写。
激励生成:从ROM按像素时序读出Bayer数据,同时生成DE、HSYNC、VSYNC。最简单的做法是计数器:h_cnt到1919时DE拉高一拍,然后换行;v_cnt到1079时一个完整帧结束,重新开始。
DUT实例:接上时钟、复位、输入输出信号。注意复位逻辑也要仿真实。
结果比对:把DUT输出的RGB数据写到一个文本文件,然后用Python脚本和golden比对,不在testbench里做复杂的判断。比对脚本统计每个像素的差值,定一个阈值:比如差值小于2的像素占比要到99.5%以上,否则报错。
还有一个细节很多人会漏:testbench里要故意往输入数据里插几个坏点,强制改成全0或全4095,验证DPC模块真的把坏点修掉了;再给一张整体偏红的图,验证AWB是否把白色拉回白色。人为制造异常输入,远比等着真实sensor自己出问题高效。
6.3 板级调试不是上来就插ILA,要有顺序
综合实现一次要跑几十分钟,每次改动都上板抓ILA,效率极低。我的顺序是先仿真通过,再上板点测试图案,确认链路完整;然后用ILA观测中间节点,比如去马赛克输出,确认数据不是全0或全F;再用VIO在线调阈值、增益、Gamma曲线的参数;最后才接真实sensor。
ILA采样深度不要盲目拉大。我一般采512到4096个点,够看一个行周期内的数据就行。如果采样深度太深、探针位宽太大,实现时间会明显变长,甚至导致布线失败。探针分组也要讲究:前级RAW一组探针,后级RGB输出一组探针,分开触发。把20个信号塞进一个ILA,触发条件复杂,反而难定位。
7. 我踩过的坑:花屏、偏色、断层
7.1 第一帧花屏:帧同步信号被模块吞了
现象是上电头几帧图像斜着撕裂,后面偶尔恢复正常。排查到去马赛克模块,发现内部多打了8拍,但v_sync没有跟着打8拍,帧起始位置不定,画面自然撕裂。解决办法前面已经说了:所有与像素数据并行的控制信号,数据打多少拍控制信号就打多少拍,一行代码都不能省。
我在代码里专门写了一个同步打拍模块,把data、valid、h_sync、v_sync打包成一个结构体,所有模块都通过这个结构体传递。这样不管内部延迟几拍,只要把结构体整体寄存,同步信号永远不会丢。
7.2 白平衡把高光推爆,画面变成一团紫色
用Gray World时,如果场景整体偏红,R_gain会被算得很小,B_gain被算得很大,下一帧B通道会被放得很大,高光区域直接饱和变成紫色。原因就是没加增益钳位。
我的做法是把R/B增益限制在0.5到2.0倍范围内,同时在高光区域让增益平滑回到1.0。具体说就是:当像素亮度超过阈值后,AWB增益做一个线性渐变衰减,避免高光偏色。
7.3 行缓存延迟没对齐,边缘出现斜条纹
这是让我最头疼的一个问题。现象是画面里物体边缘有斜向拖影,像老电视信号不好那种。排查发现BRAM读数据有固定延迟,但我的Line Buffer写地址和读地址没有把延迟算进去,导致读到的“上一行”实际是“上前两行”,卷积核在竖直方向上整体错了一行。
把读地址减掉BRAM的读延迟之后,斜条纹立刻消失。这个问题仿真很难发现,因为仿真里BRAM模型延迟是可见的,但如果激励没覆盖到特定帧位置,也很容易忽略。建议一开始就在行缓存接口前加一拍寄存器显式建模BRAM延迟,而不是靠IP的默认行为。
7.4 Gamma表位宽不一致,渐变处出同心环
这是第四坑。查找表深度我建成了256,输入12bit只取了高8位做地址,低4位直接丢弃。结果就是相邻两个输入值的跳变被放大成明显的亮度断层,画面上出现一圈一圈的同心圆环状banding。
我把查找表改成完整4096深度后断层消失。这个例子的教训是:图像处理里位宽对齐的问题非常隐蔽。波形上数据全都变了,但画面效果就是不对。后来我检查任何查表模块,第一件事就是核对地址位宽和输入数据位宽是否严格一致。
回头再看整个项目,从画数据流图到第一个RGB888图像稳定输出,我大概用了两周。其中一半时间花在仿真和调试上,真正写RTL的时间并不多。如果让我重新做一遍,我会在第一周就把Python参考模型、模块接口、同步信号规范全部定死,而不是每个模块边写边想。ISP流水线真正难的不是算法本身,而是把串行像素流和离散的算法模型之间对齐。这个感觉一旦建立起来,后面加镜头阴影、3D降噪、边缘增强,路径都是通的。
最后再分享一个小技巧:不要舍不得为中间结果加探针。我一度为了省资源,只在最终输出端看画面,结果一个偏色问题翻来覆去查了两天。后来在去马赛克输出、CCM输出各加了一组探针,一次就定位到了问题。调试工具不寒酸,才能走得快。