简介:针对FPGA与摄像头图像处理初学者,以Xilinx Spartan-6系列XC6SLX16为平台,演示如何驱动OV5640 CMOS摄像头,并通过Verilog HDL完成图像采集与中值滤波。工程覆盖摄像头寄存器配置、数据捕获、FIFO缓存及3×3窗口滤波等关键模块,适合希望掌握FPGA外设驱动、时序设计和实时图像处理流程的开发者参考。压缩包共含755个文件,约13.51MB。核心代码以v、vhd等硬件描述语言源文件为主,同时包含ISE工程文件、约束文件、综合与仿真脚本以及bit流配置文件和PDF说明,基本涵盖从编辑、综合到下载验证的完整工程链。目录按功能划分,便于按模块学习。目前已有134人学习。通过学习该工程可掌握OV5640的寄存器配置与数据读取、像素缓存与中值滤波的并行实现,以及FPGA工程调试方法。无论入门摄像头采集还是进阶图像算法硬件加速,都可获得一套可运行的参考设计。
1. 这个项目到底在解决什么
FPGA 从摄像头取图再到把图像处理结果送出去,这条链路几乎是数字图像处理入门绕不开的贯通训练:XC6SLX16 这块 Spartan-6 家族里的中端芯片,管脚和 Block RAM 都算不上充裕,但刚好能把 OV5640 的 DVP 并行输出接住;OV5640 本身又是一颗能出 500 万像素、带 SCCB 配置接口的传感器;而中值滤波作为典型的非线性空域滤波,恰恰是最适合在 FPGA 上展示“行缓存 + 滑动窗口 + 排序网络”这三板斧的算法。三者凑在一起,几乎就是把 FPGA 图像处理最常见的工作流——传感器配置、像素同步、行缓存、窗口生成、算法流水线、时序收敛——全部走了一遍。
这个项目适合两类人。一类是刚把 Verilog 语法学完、想用一个完整工程把“时钟域”“FIFO”“状态机”串起来的入门者;另一类是已经在做 FPGA 开发、但平时只写接口逻辑或只做算法仿真、想看看图像类算法在真实器件上要注意什么资源的工程师。XC6SLX16 属于 Spartan-6 家族,本身不新,但正因为不新,才逼着你把每一块 BRAM、每一个 slice 都算着用,这比在超大芯片上堆 IP 更能练出对资源的感觉。下文围绕“驱动 OV5640 采集——做中值滤波——调试与验证”这条主线,把能直接落地的做法展开。
2. OV5640 驱动与数据采集链路的搭建
2.1 先厘清 OV5640 的接口模式和像素格式
OV5640 的传感器输出接口有三种:DVP 并行、MIPI CSI-2、以及内嵌的 ISP 直接输出 YUV/RGB。这个项目标题限定在“驱动 OV5640 摄像头采集图像”,最常见、也最适合 XC6SLX16 的做法是走 DVP 并口。XC6SLX16 上没有硬核 MIPI,要用逻辑拼 MIPI RX 的差分接收和 byte align,会浪费大量 slice 和时钟资源,对一块入门级芯片来说不划算。
DVP 接口的核心信号就这几根:
PCLK:像素时钟,由传感器输出,和像素数据同步VSYNC:帧同步,高/低电平有效可配HREF:行同步,高电平期间D[9:2]上的数据有效D[9:2]:8/10 位像素数据,常用 RGB565 取高 8 位或 YUV422 取 8 位
像素格式的选择直接决定后续滤波的复杂度。OV5640 的 ISP 可以输出 RGB565、YUV422、RAW Bayer 等格式。中值滤波本身是灰度域算法,对彩色图像做逐通道滤波也可以,但 XC6SLX16 的 BRAM 总共只有 32 个 18Kb,如果对 RGB 三个通道各做一次行缓存,资源会非常紧张。常规做法是让 OV5640 直接输出 YUV422 或灰度格式,只取 Y 分量做滤波,或者输出 RGB565 后在 FPGA 内先做一个快速灰度转换。
2.2 SCCB 寄存器配置,别漏掉关键项
OV5640 的上电和配置时序有严格要求。SCCB 协议和 I2C 基本兼容,地址 8 位写地址是0x42,读地址0x43。配置的核心是:先给传感器上电,等待时钟稳定,然后通过 SCCB 写入分辨率、输出格式、时钟分频等寄存器。
// SCCB 写时序核心状态机片段 localparam IDLE = 3'd0; localparam START = 3'd1; localparam SEND_ADDR = 3'd2; localparam SEND_REG = 3'd3; localparam SEND_DATA = 3'd4; localparam STOP = 3'd5; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sccb_scl <= 1'b1; sccb_sda <= 1'b1; state <= IDLE; end else begin case (state) IDLE: begin if (cfg_start) begin state <= START; bit_cnt <= 4'd0; end end START: begin sccb_sda <= 1'b0; // SCL 高电平期间 SDA 拉低产生起始条件 sccb_scl <= 1'b1; state <= SEND_ADDR; end SEND_ADDR: begin // 8 位器件地址 0x42,逐位发送 sccb_scl <= 1'b0; sccb_sda <= dev_addr[7 - bit_cnt]; if (bit_cnt == 4'd8) begin bit_cnt <= 4'd0; state <= SEND_REG; end else begin bit_cnt <= bit_cnt + 1'b1; end end // ... endcase end end这段代码只展示了起始条件和地址发送片段,完整的 SCCB 控制器还需要应答位检测、寄存器地址发送、数据发送和停止条件。状态机的关键是把SCL拉低后再改变SDA,否则会产生虚假的起始/停止条件,这是新手最容易出错的地方——看到配置没生效,用 ILA 抓 SCCB 波形,基本都是 SDA 变化时机不对。
需要特别强调的寄存器配置:
| 寄存器地址 | 功能 | 推荐值 | 说明 |
|---|---|---|---|
| 0x3808 | 输出水平分辨率高字节 | 0x05 | 对应 1280 宽度,1080p 模式 |
| 0x3809 | 输出水平分辨率低字节 | 0x00 | 1280 |
| 0x380A | 输出垂直分辨率高字节 | 0x04 | 对应 1080 高度以下 |
| 0x380B | 输出垂直分辨率低字节 | 0x38 | 0x438 = 1080 |
| 0x3810 | 窗口水平偏移 | 0x00 | 裁切窗口,影响时序 |
| 0x3811 | 窗口水平偏移低字节 | 0x10 | 默认即可 |
| 0x3A02 | 自动曝光目标值 | 0x04 | 影响亮度,初值可不动 |
| 0x4300 | 输出格式控制 | 0x30 | YUV422,Y 在 D[9:2] 高 8 位 |
0x4300是输出格式寄存器,如果要从 YUV422 切到 RGB565,需要同时修改多个寄存器,不能只改这一个。市面上很多现成配置脚本是针对 1080p @ 15fps 调的,如果你用默认的 20MHz PCLK 去跑 1080p,会发现 HREF 周期异常、行场中断对不上,这往往是分频寄存器0x3804~0x380F没按输出分辨率联动调整导致的。建议第一版直接跑 VGA(640x480)或 720p,把链路调通后再上 1080p。
2.3 把 DVP 时序变成干净的像素流
传感器输出的VSYNC、HREF、PCLK和 FPGA 内部时钟是异步关系,必须做同步处理。XC6SLX16 上的做法是:先用 IDDR 原语把PCLK的双沿数据并成单沿,或者直接用PCLK作为采集时钟域,把像素数据寄存两拍消除亚稳态,再跨到系统时钟域。
// 用 PCLK 域寄存两拍,消除亚稳态 always @(posedge pclk or negedge rst_n) begin if (!rst_n) begin pclk_r1 <= 1'b0; pclk_r2 <= 1'b0; href_r1 <= 1'b0; href_r2 <= 1'b0; data_r1 <= 8'd0; data_r2 <= 8'd0; end else begin pclk_r1 <= pclk_in; pclk_r2 <= pclk_r1; href_r1 <= href_in; href_r2 <= href_r1; data_r1 <= data_in; data_r2 <= data_r1; end end打两拍只是最基础的同步手段,真正的问题是像素数据和HREF的对齐关系。OV5640 在HREF拉高后,第一个有效像素可能有一个时钟的延迟,具体延迟在数据手册里有说明,但手册上的典型值不一定覆盖所有配置组合。最稳妥的方法是用 ILA 抓一段HREF和D[9:2]的波形,肉眼确认数据稳定在哪一拍,然后在采集逻辑里做相应的对齐调整。
像素流在HREF有效期间每个PCLK进来一个像素,需要把这些像素按行组织起来,形成后续滤波模块能消费的行数据。这里不急着写行缓存,先把“像素有效”信号pixel_valid和pixel_data这两根信号理干净,后面的行缓存和滤波窗口都依赖这两个信号。如果这一步没做干净,后面调滤波算法时会出现图像“斜切”或“行错位”——本质上就是像素对齐出了问题,不是滤波算法本身的问题。
3. 中值滤波的算法选型与 Verilog 结构设计
3.1 为什么在 FPGA 上选中值滤波而不是均值滤波
中值滤波的核心思想是:对滑动窗口内的像素灰度值排序,取中间值作为输出。它对椒盐噪声(黑白像素随机出现)有极好的抑制效果,同时能保留边缘信息。均值滤波在去除噪声时会模糊边缘,低通特性在 FPGA 上实现更简单,只需要做累加和移位,但图像质量损失明显。中值滤波虽然没有均值滤波那种规则的高效结构,但排序网络在硬件上是完全可并行的,非常适合 FPGA。
在具体实现中要区分几个不同深度的方案,选型会直接影响资源消耗:
| 方案 | 窗口大小 | 排序方法 | 资源估算 | 适用场景 |
|---|---|---|---|---|
| 朴素全排序 | 3x3 | 9 个数两两比较排序网络 | 约 30-40 个 slice | 入门学习,逻辑清晰 |
| 行缓存 + 排序网络 | 3x3 | 分成三个 3 输入排序器 + 合并 | 约 20-25 个 slice | 工程常用,平衡好 |
| 列缓存复用 | 3x3 | 每列独立排序,滑动时复用比较结果 | 约 15-20 个 slice | 资源紧张时的优化做 |
| 5x5 窗口 | 5x5 | 先列排序再行排序 | 100+ slice | 噪声较多时,但 XC6SLX16 吃力 |
对 XC6SLX16 来说,3x3 窗口中值滤波是最务实的起点。5x5 窗口会消耗大量 slice 和布线资源,而 XC6SLX16 的逻辑单元本来就不多,留给后续图像处理的空间会被挤占。标题写的是“实现中值滤波”,没有特指窗口大小,行业默认做法就是 3x3。
3.2 行缓存 + 滑动窗口是标准套路
3x3 窗口需要同时拿到第 N-1 行、第 N 行、第 N+1 行的同一列数据。像素是一个一个进来的,同一时刻只有一个像素点,所以要先把前两行数据存下来,等第三行数据到来时,三行数据才能对齐。这就是“行缓存”的用途。
XC6SLX16 上有 32 个 18Kb 的 Block RAM。如果图像宽度是 640,用 8 位灰度像素,一行数据是 640x8 = 5120 bit,两个行缓存共 10240 bit,远小于一个 18Kb BRAM。甚至在 720p 下(1280x8 = 10240 bit),两个行缓存也才 20480 bit,用两个 BRAM 就能放下。如果做 RGB565 三通道滤波,数据量翻倍,BRAM 就开始紧张了。
// 行缓存核心逻辑:用 Xilinx 的原语或者推断式双口 RAM // 下面是一个用 BRAM 推断的行缓存模块示意 module line_buffer #( parameter DATA_WIDTH = 8, parameter LINE_WIDTH = 640, parameter LINE_NUM = 2 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] din, input wire wr_en, output wire [DATA_WIDTH-1:0] dout_line0, // 第 N-1 行输出 output wire [DATA_WIDTH-1:0] dout_line1 // 第 N 行输出 ); reg [DATA_WIDTH-1:0] mem [0:LINE_WIDTH-1]; reg [9:0] wr_addr; reg [9:0] rd_addr; always @(posedge clk) begin if (wr_en) begin mem[wr_addr] <= din; end end always @(posedge clk) begin wr_addr <= (wr_addr == LINE_WIDTH-1) ? 0 : wr_addr + 1'b1; end always @(posedge clk) begin rd_addr <= wr_addr; // 读地址跟踪写地址,延迟一拍 end assign dout_line0 = mem[rd_addr]; assign dout_line1 = mem[wr_addr]; // 写端口直接读出当前数据 endmodule这段代码用数组推断 BRAM,写地址循环走完一行后回到 0,读地址比写地址延迟一拍读出上一行数据。dout_line1直接从写端口读,拿到的是当前正在写入的像素,dout_line0是上一行的对应像素。两个行缓存级联起来,就能构造三行数据同时有效。
注意 BRAM 的读操作有一个时钟延迟,rd_addr <= wr_addr这条语句意味着读地址是当前写地址的延迟版本,读出的数据自然就对齐到了正确的行位置。常见的错误是把读地址和写地址同步更新,导致行数据错位一拍,图像上表现为整体偏色或者行间闪烁。
3.3 三个 3 输入排序器 + 全排序网络
拿到三行同一列的数据后,需要在一个窗口内凑齐 3x3 共 9 个像素。这 9 个像素分别是:第 N-1 行的第 j-1、j、j+1 列,第 N 行的第 j-1、j、j+1 列,第 N+1 行的第 j-1、j、j+1 列。
硬件上要生成这个窗口,至少需要两级移位寄存器。每一行数据进来后,先用两个寄存器暂存前两个像素,当第三个像素到来时,三列数据同时有效。再加上行缓存提供的三行并行数据,合在一起才是真正的 3x3 窗口。
// 窗口生成逻辑:对每行做列方向的两个像素延迟 reg [7:0] line0_col0, line0_col1, line0_col2; reg [7:0] line1_col0, line1_col1, line1_col2; reg [7:0] line2_col0, line2_col1, line2_col2; always @(posedge clk) begin if (pixel_valid) begin line0_col2 <= line0_col1; line0_col1 <= line0_col0; line0_col0 <= dout_line0; // 第 N-1 行当前像素 line1_col2 <= line1_col1; line1_col1 <= line1_col0; line1_col0 <= dout_line1; line2_col2 <= line2_col1; line2_col1 <= line2_col0; line2_col0 <= pixel_data; // 第 N+1 行当前像素 end end9 个像素到位后,要做全排序。取 9 个数的中值,不一定要把 9 个数完全排序,只需要第 5 大的数。但为了编码简单和时序清晰,工程上常用的是三级排序网络:先对每行的 3 个数做排序得到行内 max/mid/min,再对三个列做同样的比较。这样 9 个数的中值可以通过如下推导获得:
// 3 输入排序器 function [7:0] max3; input [7:0] a, b, c; reg [7:0] t; begin t = (a > b) ? a : b; max3 = (t > c) ? t : c; end endfunction // 中值 = max(min_col_max, mid_col_mid, max_col_min) 的一种变形 // 更通用的做法是调用系统任务 $signed 比较实际上完整的 3x3 中值排序网络有很多种等价写法。最稳妥的工程方案是直接写出 9 个数的冒泡排序比较网络,用组合逻辑实现,虽然消耗一些 LUT,但逻辑清晰,不容易出错。XC6SLX16 有 2278 个 slice,每个 slice 4 个 LUT,做 9 个数全排序大约 30-40 个 slice,完全在承受范围内。
// 9 个数取中值的完整比较网络(节选) wire [7:0] p00 = line0_col0, p01 = line0_col1, p02 = line0_col2; wire [7:0] p10 = line1_col0, p11 = line1_col1, p12 = line1_col2; wire [7:0] p20 = line2_col0, p21 = line2_col1, p22 = line2_col2; // 行内排序 3 组 wire [7:0] row0_min, row0_mid, row0_max; wire [7:0] row1_min, row1_mid, row1_max; wire [7:0] row2_min, row2_mid, row2_max; sort3 u_sort00(.a(p00), .b(p01), .c(p02), .min(row0_min), .mid(row0_mid), .max(row0_max)); sort3 u_sort01(.a(p10), .b(p11), .c(p12), .min(row1_min), .mid(row1_mid), .max(row1_max)); sort3 u_sort02(.a(p20), .b(p21), .c(p22), .min(row2_min), .mid(row2_mid), .max(row2_max)); // 列向取中值 median_of_3 u_final( .a(row0_mid), .b(row1_mid), .c(row2_mid), .dout(filter_out) );这里sort3是一个 3 输入排序子模块,内部比较器输出 min/mid/max 三个值。取中值的理论依据是:三行的 mid 值中再取中值,就能得到 3x3 窗口的真正中值。这个结论成立的前提是行内排序的 mid 是五数中值的一部分,数学推导可以查阅相关文献,这里直接给出结论——这个结构比全排序少一半多的比较器,时序也好收敛。
3.4 边缘像素的处理策略
3x3 窗口在图像边界处会越界,第一行、最后一行、第一列、最后一列的像素无法凑齐完整的 9 个点。常见处理策略有三种:
| 策略 | 做法 | 优缺点 |
|---|---|---|
| 直接丢弃 | 边界像素不输出,缩小输出图像 | 实现最简单,图像尺寸变小 |
| 复制边缘 | 把边界像素复制一份填充缺失位置 | 输出尺寸不变,但会引入少量伪影 |
| 零填充 | 缺失位置填 0 | 实现简单,但滤波结果偏暗 |
工程上最常用的是复制边缘。实现方式是在行缓存读出端做判断:如果当前行列坐标处于边界,就复制相邻像素的数值。这个逻辑增加的资源极少,只需要几组多路选择器,但能把输出图像尺寸保持完整,后续如果要叠加显示或做帧差,尺寸统一会很方便。
4. XC6SLX16 资源约束与设计取舍
4.1 Spartan-6 的逻辑资源和 BRAM 分配
XC6SLX16 的具体资源:逻辑单元 14579 个,也就是约 2278 个 slice(每个 slice 4 个 LUT 和 4 个 FF),Block RAM 共 32 个 18Kb(合计 576Kb),DSP48A1 共 32 个。对于图像采集 + 中值滤波这个任务,BRAM 是主要约束,slice 用量相对宽裕。
做一个精细的资源估算:
- 行缓存 2 个,每个 640x8 位 = 5120 bit,用 1 个 BRAM,共 2 个 BRAM
- 如果做到 1280x720,每行 10240 bit,仍然 1 个 BRAM 能放下,共 2 个 BRAM
- 帧缓存不走 BRAM,需要用外部 DDR(Spartan-6 要接 DDR2/DDR3 或直接用内部 BRAM 做小规模 FIFO)
- 排序网络组合逻辑约 40 slice
- SCCB 控制器加 FIFO 控制逻辑约 20 slice
总 BRAM 用量约 4 个左右(如果加上输出端的行缓冲),离 32 个 BRAM 的上限还远。这块芯片真正受限的是时钟频率和 I/O 数量。XC6SLX16 的速度等级如果是 -2,PCLK 最高大约能跑到 100-150MHz,OV5640 在 1080p 下需要 72MHz PCLK,720p 下 48MHz 够用。确保综合后的时序约束里 PCLK 到达采集逻辑的路径不要过长。
4.2 数据跨时钟域的实战处理
OV5640 的 PCLK 和 FPGA 系统时钟(通常是 50MHz 或 100MHz)是异步的。整个数据处理链路有两种架构选择:
方案一:全链路用 PCLK 做像素时钟,SCCB 配置用系统时钟。像素从采集到滤波再到输出全部在 PCLK 域,只在输出端(比如 DDR 写入或显示驱动)做跨时钟域转换。这个方案简单,中值滤波的排序网络都是组合逻辑,不需要额外时钟,只要寄存器满足建立时间即可。
方案二:把像素数据先跨到系统时钟域,之后所有处理都用系统时钟。这个方案对时序收敛更友好,因为系统时钟通常是板上最干净的时钟,但需要一个异步 FIFO 来缓冲跨时钟域的数据。
// 异步 FIFO 例化(Xilinx FIFO Generator 或手写) fifo_generator_0 u_fifo ( .rst(~rst_n), .wr_clk(pclk), .wr_en(pixel_valid), .din(pixel_data), .rd_clk(sys_clk), .rd_en(sys_rd_en), .dout(sys_pixel_data), .full(fifo_full), .empty(fifo_empty) );我一般建议用方案一,把跨时钟域点放在最后。原因很简单:中值滤波的行缓存本身在 PCLK 域工作,读写都在同一个时钟下,没有跨时钟域问题;如果方案二,行缓存要同时处理 PCLK 域写入和 sys_clk 域读出,需要把 BRAM 配成伪双口(写时钟和读时钟分离),资源消耗和复杂度都会上升。只在最终输出到 VGA 或 DDR 时做一个异步 FIFO 过渡,这个 FIFO 的深度按一行像素的 1.5 倍留够即可。
4.3 时序约束和综合技巧
XC6SLX16 是 Xilinx Spartan-6,开发环境是 ISE 14.7(虽然老,但稳定)。新建工程时选择芯片型号 XC6SLX16-2CSG324 或 FTG256 封装,在综合选项里把 Optimization Goal 设为 Speed,Optimization Effort 设为 High。生成比特流前,必须手动添加时序约束文件(UCF,不是 XDC——Spartan-6 时代还是 UCF)。
NET "pclk" TNM_NET = "pclk_group"; TIMESPEC TS_pclk = PERIOD "pclk_group" 48 MHz HIGH 50%; NET "vsync_in" TIG; NET "href_in" TIG; NET "sccb_sda" TIG;第一行约束 PCLK 为 48MHz,对应 720p 的像素时钟。TIG(Timing Ignore)标记在 VSYNC、HREF、SCCB 这些慢速控制线上,告诉工具这些信号不需要做严格的时序分析,能减少综合器在这些异步信号上花的大量优化时间。VSYNC 和 HREF 的频率很低,亚稳态的概率也低,加上同步器打两拍后,TIG 是安全的。
布局布线后要在 ISE 里看时序报告,重点关注pclk到采集寄存器的路径 slack 是否为正。如果出现负 slack,优先看是不是行缓存的地址逻辑路径太长,把地址计数器拆成两个 always 块,或者用寄存器打一拍再做比较,通常能解决。
5. 仿真验证与上板调试的关键点
5.1 用 Testbench 构造带椒盐噪声的图像输入
写 HDL 最容易犯的错误是一上来就综合上板,结果图像花了也不知道是输入问题还是算法问题。严格的做法是先做仿真验证:用 $readmemh 或系统任务读入一张图像数据,在 Testbench 里模拟 OV5640 的时序(PCLK 翻转、VSYNC 拉高、HREF 按行拉高),喂给 DUT,再把 DUT 的输出写回文件,放到 PC 上用 Python 或 MATLAB 画出来看效果。
# 用 Python 生成一张带椒盐噪声的灰度图,转成 hex 文件供仿真读取 import numpy as np from PIL import Image # 生成 640x480 灰度图并加噪声 img = np.random.randint(0, 256, (480, 640), dtype=np.uint8) # 5% 椒盐噪声 noise_mask = np.random.rand(480, 640) < 0.05 img[noise_mask] = np.random.choice([0, 255], size=img[noise_mask].shape) with open("img_in.hex", "w") as f: for row in img: for pixel in row: f.write(f"{pixel:02x}\n")仿真的输出用$fwrite写进img_out.hex,再用 Python 读回来显示:
# 把仿真输出转回图像 import numpy as np from PIL import Image data = np.fromfile("img_out.hex", dtype=np.uint8, sep="\n") img_out = data.reshape(480, 640) Image.fromarray(img_out, "L").save("filtered.png")仿真能验证算法正确性,但仿真里没有 BRAM 的读写延迟模型偏差,也没有 PCLK 抖动,所以仿真通过只代表逻辑对。上板后图像如果出现噪点残留,大概率是配置的曝光、增益参数让图像本身过曝或过暗,导致噪声特征和仿真假设不符。
5.2 用 ILA 抓 VSYNC/HREF/像素对齐
上板调试时,如果输出图像出现规则性的条纹或者色彩混乱,第一时间要抓的是一帧图像内 HREF 拉高的行数和每行的像素计数。OV5640 输出的行场信息如果和 FPGA 侧配置的行列数统计不一致,说明传感器的配置寄存器没生效,或是 SCCB 的写操作在某几个寄存器上失败了。
// 上板调试用的行场计数统计 reg [9:0] href_cnt; // HREF 高电平持续计数 reg [15:0] vsync_cnt; // VSYNC 之间的 PCLK 计数 reg [11:0] line_valid_cnt; // HREF 脉动次数 always @(posedge pclk or negedge rst_n) begin if (!rst_n) begin href_cnt <= 0; line_valid_cnt <= 0; end else if (vsync_pos) begin // 帧起始,打印上一帧统计 line_valid_cnt <= 0; end else if (href_in) begin href_cnt <= href_cnt + 1'b1; end else begin if (href_cnt != 0) begin line_valid_cnt <= line_valid_cnt + 1'b1; href_cnt <= 0; end end end把line_valid_cnt接到 ILA 的探针上,和 OV5640 手册标称的行数对比。如果行数少了,先查寄存器0x380E和0x380F(垂直总行数)是否配置正确;如果行数对但图像偏的,查0x3810/0x3811窗口偏移。这一步能把“传感器配置问题”和“FPGA 逻辑问题”彻底分开。
提示:SCCB 配置完成后,要等待至少 10 帧(约 0.5 秒)再开始采集,因为传感器内部的自动曝光和自动白平衡需要收敛时间。上电瞬间抓到的第一帧往往是花屏或半黑的,这是正常现象。
5.3 输出效果的客观验证
单凭人眼观察判断滤波效果不够严谨,我建议在 FPGA 内同时输出滤波前后的灰度直方图统计。用简单逻辑统计 0~255 每个灰度级出现的次数,通过串口或者片上逻辑分析仪把直方图数据导出。椒盐噪声的特征是灰度值集中在 0 和 255 两端有过冲峰,中值滤波后的直方图应该明显削平这两个端点的峰值,而中间灰度分布基本不变。
6. 几个能直接提升效果的进阶处理技巧
中值滤波本身只是一个模块,项目标题要真正“能看”,还要配合几个专门针对 XC6SLX16 和 OV5640 的实用技巧。第一个是让 OV5640 的 ISP 直接输出 8 位灰度格式,跳过 FPGA 片内的 RGB 转灰度逻辑。OV5640 的寄存器中可以通过0x4300配置 YUV422 格式,此时高字节就是 Y 分量,FPGA 侧只取高 8 位,相当于硬件免费送了一个灰度通道,省掉了 RGB 加权平均的乘法器,也省掉了 3 通道行缓存。这个技巧能让 BRAM 占用直接减半。
第二个技巧是在中值滤波的输出端加一个简单的“若当前像素和周围像素差过大则替换”的后处理条件。中值滤波对随机椒盐噪声有效,但对连片的坏点线(同一行连续多个像素异常)效果有限——因为 3x3 窗口内坏点可能占多数,中值本身就被污染了。这时可以在滤波输出后再加一级比较:如果当前输出值距窗口内最大和最小值都很远,且和九个像素的中值差异超过一个阈值,就把输出抑制为前一行同列的值。这个处理对 OV5640 在低照度下偶尔产生的行噪声有奇效,代价只是增加 3 个比较器和 1 个寄存器。
第三个技巧是用直方图统计来辅助判断场景亮暗,并自动切换阈值。具体做法是在 FPGA 里统计一帧图像灰度值的均值,存到寄存器里,下一帧滤波时用这个均值动态调整 OV5640 的曝光寄存器。OV5640 的自动曝光本身可用,但在光线突变场景(如摄像头从窗口转到室内)收敛很慢,FPGA 侧通过 SCCB 写入0x3A0F到0x3A10等曝光步长寄存器,能把收敛时间缩短一半以上。XC6SLX16 剩余的逻辑资源足够支撑直方图统计加阈值管理这一小段控制逻辑。
// 帧均值计算:用累加器在帧内对像素求和,帧结束求平均 reg [23:0] sum_pixel; // 720p 下一帧约 92 万像素,12 位累加器不够 reg [19:0] frame_cnt; always @(posedge pclk or negedge rst_n) begin if (!rst_n) begin sum_pixel <= 0; frame_cnt <= 0; end else if (vsync_pos) begin avg_pixel <= sum_pixel / frame_cnt; sum_pixel <= 0; frame_cnt <= 0; end else if (pixel_valid) begin sum_pixel <= sum_pixel + pixel_data; frame_cnt <= frame_cnt + 1'b1; end endsum_pixel用 24 位是因为 8 位像素最大值 255 乘以 720p 的 921600 个像素,需要约 28 位才保险。用 24 位在极端全白画面下会溢出,工程上留 28 位最稳妥。算出的均值可以作为曝光补偿的反馈:均值偏低就提高曝光,偏高就降低曝光。
最后提醒一个 XC6SLX16 特有的坑。Spartan-6 的 BRAM 在初始配置时输出为 0,但如果行缓存读出端没有做复位初始化,仿真正常、上板就会出现前几行颜色异常。解决方法是给行缓存输出端加一个按行计的复位使能——只有当前行号大于等于 2 时才让滤波输出有效,前两行直接让原始像素透传。这个细节往往就是整个工程从“图像发花”到“图像清晰”的最后一步:透传方式损失边界效果,但人眼基本看不出;滤波输出不完整时,画面顶部和左边缘的彩色条纹会非常突兀。
本文还有配套的精品资源,点击获取