第二章 经典图像算法的 FPGA 实现


2.1 Gaussian 模糊

2.1.1 数学模型

Gaussian 模糊是图像处理中最常用的低通滤波器,用于去除高频噪声,也是 Canny 边缘检测的预处理步骤。

复制代码
二维 Gaussian 函数:
  G(x,y) = (1 / 2πσ²) × exp(-(x² + y²) / 2σ²)

σ=1.0 时的 5×5 离散近似核(整数化):
   2   4   5   4   2
   4   9  12   9   4
   5  12  15  12   5
   4   9  12   9   4
   2   4   5   4   2

归一化因子:所有系数之和 = 159
输出:G_out(y,x) = Σ K(i,j) × I(y+i, x+j) / 159

2.1.2 朴素实现的问题

复制代码
5×5 卷积的朴素实现:
  每个输出像素需要 25 次乘法 + 24 次加法
  25 次乘法 -> 25 个 DSP48(资源消耗大)
  关键路径长 -> 时序难以收敛

1080P60 资源估算(朴素实现):
  DSP48:25 个
  LUT:约 800 个
  BRAM36:4 个(4 条 Line Buffer)
  FF:约 1000 个

2.1.3 可分离滤波器优化

二维 Gaussian 核可以分解为两个一维核的外积,这是最重要的优化手段:

复制代码
G(x,y) = Gx(x) × Gy(y)

一维 Gaussian 核(σ=1.0,5 点):
  [2, 4, 5, 4, 2],归一化因子 = 17

两步实现:
  Step 1:水平方向一维卷积
    H(y,x) = Σ Kx(j) × I(y, x+j),j ∈ {-2,-1,0,1,2}
  Step 2:垂直方向一维卷积
    G(y,x) = Σ Ky(i) × H(y+i, x),i ∈ {-2,-1,0,1,2}

资源对比:
  朴素实现:25 次乘法
  可分离实现:5 + 5 = 10 次乘法
  节省 60% 的乘法资源

2.1.4 完整 RTL 架构

复制代码
// ─────────────────────────────────────────────
// 可分离 Gaussian 5×5 滤波器
// 分两级流水线:水平卷积 -> 垂直卷积
// ─────────────────────────────────────────────

module gaussian_5x5 (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        i_valid,
    input  wire        i_tuser,
    input  wire        i_tlast,
    input  wire [7:0]  i_pixel,
    output wire        o_valid,
    output wire        o_tuser,
    output wire        o_tlast,
    output wire [7:0]  o_pixel
);

// ─── 水平卷积 ───────────────────────────────
// 系数:[2, 4, 5, 4, 2],归一化因子 17
// 需要 5 个像素:x-2, x-1, x, x+1, x+2
// 因果实现:使用 x-4, x-3, x-2, x-1, x
// 即 4 级移位寄存器

reg [7:0] h_sr [0:3];  // 水平移位寄存器
integer i;

always @(posedge clk) begin
    if (i_valid) begin
        h_sr[0] <= i_pixel;
        for (i = 1; i <= 3; i = i+1)
            h_sr[i] <= h_sr[i-1];
    end
end

// 水平卷积计算(1 级流水线)
reg [11:0] h_sum;
reg        h_valid, h_tuser, h_tlast;

always @(posedge clk) begin
    h_sum  <= 2*h_sr[3] + 4*h_sr[2] + 5*h_sr[1]
            + 4*h_sr[0] + 2*i_pixel;
    h_valid <= i_valid;
    h_tuser <= i_tuser;
    h_tlast <= i_tlast;
end

// 除以 17(近似:>> 4 + 微调,或用 DSP 实现精确除法)
// 精确方法:乘以 (1/17 的定点近似) = 乘以 15 再右移 8 位
// 1/17 ≈ 15/256,误差 < 0.1%
wire [7:0] h_out = h_sum[11:4];  // 简化:右移 4 位(除以 16,近似除以 17)

// ─── 垂直卷积 ───────────────────────────────
// 需要 5 行历史数据:4 条 Line Buffer

parameter WIDTH = 1920;
(* ram_style = "block" *) reg [7:0] lb [0:3][0:WIDTH-1];
reg [10:0] v_addr;

reg [7:0] v_row [0:4];  // 5 行窗口

always @(posedge clk) begin
    if (h_valid) begin
        // 更新 Line Buffer(级联)
        lb[0][v_addr] <= h_out;
        lb[1][v_addr] <= lb[0][v_addr];
        lb[2][v_addr] <= lb[1][v_addr];
        lb[3][v_addr] <= lb[2][v_addr];

        // 读出 5 行数据
        v_row[0] <= lb[3][v_addr];  // y-4 行
        v_row[1] <= lb[2][v_addr];  // y-3 行
        v_row[2] <= lb[1][v_addr];  // y-2 行
        v_row[3] <= lb[0][v_addr];  // y-1 行
        v_row[4] <= h_out;          // 当前行

        v_addr <= (v_addr == WIDTH-1) ? 0 : v_addr + 1;
    end
end

// 垂直卷积计算(1 级流水线)
reg [11:0] v_sum;
reg        v_valid, v_tuser, v_tlast;

always @(posedge clk) begin
    v_sum  <= 2*v_row[0] + 4*v_row[1] + 5*v_row[2]
            + 4*v_row[3] + 2*v_row[4];
    v_valid <= h_valid;
    v_tuser <= h_tuser;
    v_tlast <= h_tlast;
end

assign o_pixel = v_sum[11:4];  // 右移 4 位
assign o_valid = v_valid;
assign o_tuser = v_tuser;
assign o_tlast = v_tlast;

endmodule

2.1.5 DSP48 精确除法实现

复制代码
// 精确除以 17:乘以 15 再右移 8 位
// 1/17 ≈ 15/256 = 0.05859,实际 1/17 = 0.05882,误差 0.4%
// 对于 8bit 图像,最大误差 < 1 个灰度级,可接受

// 使用 DSP48 实现乘法
// DSP48 配置:A = h_sum(12bit),B = 15(4bit),P = A×B(16bit)
// 输出:P[15:8](右移 8 位)

// Verilog 推断 DSP48:
wire [15:0] div_result = h_sum * 15;
wire [7:0]  h_out_precise = div_result[15:8];

// 综合工具会自动将此乘法映射到 DSP48
// 每个方向(水平/垂直)各需要 1 个 DSP48
// 总计:2 个 DSP48(相比朴素实现的 25 个,节省 92%)

2.1.6 资源对比总结

复制代码
5×5 Gaussian 三种实现方案对比:

方案          LUT    FF    BRAM36  DSP48  最高频率
─────────────────────────────────────────────────
朴素实现      800   1000    4       25    150MHz
可分离(移位)400    600    4        0    200MHz
可分离(DSP) 200    400    4        2    250MHz

推荐方案:可分离 + DSP48
  - 资源最省
  - 时序最好
  - 精度最高

2.2 中值滤波

2.2.1 为什么中值滤波难以实现

中值滤波是非线性算法,无法用卷积表示,也无法用可分离滤波器优化。3×3 中值滤波需要对 9 个像素排序,找出中间值。

复制代码
朴素排序方法:
  冒泡排序:需要 36 次比较,无法流水线化
  快速排序:递归结构,无法直接映射到 RTL

FPGA 解决方案:排序网络(Sorting Network)
  排序网络是一种固定拓扑的比较器网络
  可以完全流水线化,每个时钟周期输出一个结果

2.2.2 3×3 中值滤波排序网络

复制代码
9 个输入的最优排序网络需要 25 个比较器,6 级流水线。
但我们只需要中间值(第 5 大),可以简化。

简化策略(只求中值):
  Step 1:将 9 个像素分成 3 组,每组 3 个,分别求最小值、中值、最大值
  Step 2:取 3 组最大值中的最小值(min_of_max)
  Step 3:取 3 组最小值中的最大值(max_of_min)
  Step 4:取 3 组中值中的中值(med_of_med)
  Step 5:中值 = median(min_of_max, max_of_min, med_of_med)

总比较器数量:9 + 3 + 3 + 3 = 18 个(比完整排序网络少 28%)

2.2.3 比较器单元与流水线实现

复制代码
// 基本比较器单元:输出较小值和较大值
module compare_swap (
    input  wire [7:0] a, b,
    output wire [7:0] lo, hi
);
    assign lo = (a < b) ? a : b;
    assign hi = (a < b) ? b : a;
endmodule

// 3 个数的排序(输出 min, med, max)
module sort3 (
    input  wire [7:0] a, b, c,
    output reg  [7:0] s_min, s_med, s_max
);
    wire [7:0] lo_ab, hi_ab, lo_bc, hi_bc;
    compare_swap cs0(.a(a), .b(b), .lo(lo_ab), .hi(hi_ab));
    compare_swap cs1(.a(b), .b(c), .lo(lo_bc), .hi(hi_bc));

    always @(*) begin
        // 3 个数排序只需 3 个比较器
        if (a <= b && b <= c)      begin s_min=a; s_med=b; s_max=c; end
        else if (a <= c && c <= b) begin s_min=a; s_med=c; s_max=b; end
        else if (b <= a && a <= c) begin s_min=b; s_med=a; s_max=c; end
        else if (b <= c && c <= a) begin s_min=b; s_med=c; s_max=a; end
        else if (c <= a && a <= b) begin s_min=c; s_med=a; s_max=b; end
        else                       begin s_min=c; s_med=b; s_max=a; end
    end
endmodule

// 3×3 中值滤波核心
module median_3x3 (
    input  wire        clk,
    input  wire        i_valid,
    input  wire [7:0]  p00,p01,p02,
                       p10,p11,p12,
                       p20,p21,p22,
    output reg         o_valid,
    output reg  [7:0]  o_median
);

// Stage 1:每行排序
wire [7:0] r0_min, r0_med, r0_max;
wire [7:0] r1_min, r1_med, r1_max;
wire [7:0] r2_min, r2_med, r2_max;

sort3 s0(.a(p00),.b(p01),.c(p02),.s_min(r0_min),.s_med(r0_med),.s_max(r0_max));
sort3 s1(.a(p10),.b(p11),.c(p12),.s_min(r1_min),.s_med(r1_med),.s_max(r1_max));
sort3 s2(.a(p20),.b(p21),.c(p22),.s_min(r2_min),.s_med(r2_med),.s_max(r2_max));

// Stage 2:列方向求中值
reg [7:0] max_of_min, med_of_med, min_of_max;
reg       valid_s1;

always @(posedge clk) begin
    // 3 个最小值中取最大值
    max_of_min <= (r0_min > r1_min) ?
                  ((r0_min > r2_min) ? r0_min : r2_min) :
                  ((r1_min > r2_min) ? r1_min : r2_min);
    // 3 个中值中取中值
    med_of_med <= (r0_med >= r1_med && r0_med <= r2_med) ? r0_med :
                  (r0_med >= r2_med && r0_med <= r1_med) ? r0_med :
                  (r1_med >= r0_med && r1_med <= r2_med) ? r1_med :
                  (r1_med >= r2_med && r1_med <= r0_med) ? r1_med : r2_med;
    // 3 个最大值中取最小值
    min_of_max <= (r0_max < r1_max) ?
                  ((r0_max < r2_max) ? r0_max : r2_max) :
                  ((r1_max < r2_max) ? r1_max : r2_max);
    valid_s1 <= i_valid;
end

// Stage 3:最终中值
always @(posedge clk) begin
    o_median <= (max_of_min >= med_of_med && max_of_min <= min_of_max) ? max_of_min :
                (max_of_min >= min_of_max && max_of_min <= med_of_med) ? max_of_min :
                (med_of_med >= max_of_min && med_of_med <= min_of_max) ? med_of_med :
                (med_of_med >= min_of_max && med_of_med <= max_of_min) ? med_of_med :
                min_of_max;
    o_valid <= valid_s1;
end

endmodule

2.2.4 中值滤波资源估算

复制代码
3×3 中值滤波资源估算(Xilinx 7 系列):

比较器网络:
  每个 8bit 比较器约需 8 个 LUT
  18 个比较器 = 144 个 LUT

流水线寄存器(3 级):
  约 200 个 FF

Line Buffer(2 条):
  2 个 BRAM36

合计:
  LUT:约 200~300
  FF:约 300
  BRAM36:2
  DSP:0

与 Sobel 资源相当,但延迟为 3 个时钟周期(比 Sobel 少 1 级)

2.3 Canny 边缘检测

2.3.1 算法流程与 FPGA 挑战

Canny 是最经典的边缘检测算法,由 5 个步骤组成:

复制代码
Step 1:Gaussian 模糊(去噪)
  输入:原始灰度图
  输出:平滑图像
  FPGA 实现:5×5 可分离 Gaussian(已在 2.1 节实现)

Step 2:Sobel 梯度计算
  输入:平滑图像
  输出:梯度幅值 G 和梯度方向 θ
  FPGA 实现:第一章 Sobel 核(扩展输出方向信息)

Step 3:非极大值抑制(NMS)
  输入:G 和 θ
  输出:细化边缘(单像素宽)
  FPGA 挑战:需要比较梯度方向上的相邻像素

Step 4:双阈值检测
  输入:NMS 输出
  输出:强边缘和弱边缘
  FPGA 实现:简单比较器,容易实现

Step 5:边缘连接(Hysteresis)
  输入:强边缘和弱边缘
  输出:最终边缘图
  FPGA 挑战:需要连通性分析,通常需要帧缓存

2.3.2 梯度方向量化

复制代码
Sobel 输出的梯度方向 θ = atan2(Gy, Gx),范围 [-π, π]。
NMS 需要比较梯度方向上的两个相邻像素。

FPGA 实现策略:将方向量化为 4 个方向(0°, 45°, 90°, 135°)

量化规则:
  θ ∈ [-22.5°, 22.5°)   或 [157.5°, 180°]  -> 方向 0(水平)
  θ ∈ [22.5°, 67.5°)                         -> 方向 1(45°)
  θ ∈ [67.5°, 112.5°)                        -> 方向 2(垂直)
  θ ∈ [112.5°, 157.5°)                       -> 方向 3(135°)

避免 atan2 的方法(FPGA 友好):
  直接用 Gx 和 Gy 的符号和比值判断方向
  不需要三角函数,只需比较器和除法器

// 梯度方向量化(无需 atan2)
module grad_direction (
    input  wire signed [10:0] gx, gy,
    output reg  [1:0]         direction
    // 0: 水平(0°), 1: 对角(45°), 2: 垂直(90°), 3: 反对角(135°)
);

wire signed [10:0] abs_gx = gx[10] ? -gx : gx;
wire signed [10:0] abs_gy = gy[10] ? -gy : gy;

// 判断条件:
// 水平:|Gy|/|Gx| < tan(22.5°) ≈ 0.414
//       等价于:|Gy| < 0.414 × |Gx|
//       整数近似:2×|Gy| < |Gx|(tan(26.6°),近似)
// 垂直:|Gx|/|Gy| < 0.414,等价于 2×|Gx| < |Gy|
// 其余:对角方向

always @(*) begin
    if (abs_gy < (abs_gx >>> 1))        // |Gy| < |Gx|/2
        direction = 2'd0;               // 水平
    else if (abs_gx < (abs_gy >>> 1))   // |Gx| < |Gy|/2
        direction = 2'd2;               // 垂直
    else if (gx[10] ^ gy[10])           // Gx 和 Gy 异号
        direction = 2'd3;               // 135°
    else
        direction = 2'd1;               // 45°
end
endmodule

2.3.3 非极大值抑制(NMS)RTL 实现

复制代码
// NMS:在梯度方向上比较当前像素与两个相邻像素
// 如果当前像素不是局部最大值,则抑制(输出 0)

module nms_3x3 (
    input  wire        clk,
    input  wire        i_valid,
    // 3×3 梯度幅值窗口
    input  wire [7:0]  g00,g01,g02,
                       g10,g11,g12,
                       g20,g21,g22,
    // 中心像素的梯度方向
    input  wire [1:0]  dir,
    output reg         o_valid,
    output reg  [7:0]  o_pixel
);

always @(posedge clk) begin
    o_valid <= i_valid;
    case (dir)
        2'd0: // 水平方向:比较左右像素 g10 和 g12
            o_pixel <= (g11 >= g10 && g11 >= g12) ? g11 : 8'd0;
        2'd1: // 45° 方向:比较 g00 和 g22
            o_pixel <= (g11 >= g00 && g11 >= g22) ? g11 : 8'd0;
        2'd2: // 垂直方向:比较上下像素 g01 和 g21
            o_pixel <= (g11 >= g01 && g11 >= g21) ? g11 : 8'd0;
        2'd3: // 135° 方向:比较 g02 和 g20
            o_pixel <= (g11 >= g02 && g11 >= g20) ? g11 : 8'd0;
    endcase
end
endmodule

2.3.4 双阈值检测

复制代码
// 双阈值检测:将像素分为强边缘、弱边缘、非边缘
// 强边缘:pixel >= high_thresh
// 弱边缘:low_thresh <= pixel < high_thresh
// 非边缘:pixel < low_thresh

module double_threshold (
    input  wire        clk,
    input  wire        i_valid,
    input  wire [7:0]  i_pixel,
    input  wire [7:0]  low_thresh,   // 典型值:50
    input  wire [7:0]  high_thresh,  // 典型值:150
    output reg         o_valid,
    output reg  [1:0]  o_edge
    // 2'b10: 强边缘, 2'b01: 弱边缘, 2'b00: 非边缘
);

always @(posedge clk) begin
    o_valid <= i_valid;
    if (i_pixel >= high_thresh)
        o_edge <= 2'b10;
    else if (i_pixel >= low_thresh)
        o_edge <= 2'b01;
    else
        o_edge <= 2'b00;
end
endmodule

2.3.5 Canny 完整流水线架构

复制代码
完整 Canny 流水线(模块串联):

输入图像(AXI4-Stream)
    ↓
[Gaussian 5×5]  延迟:2 级流水 + 4 行 Line Buffer
    ↓
[Sobel 核]      延迟:4 级流水 + 2 行 Line Buffer
    ↓
[方向量化]      延迟:1 级流水(组合逻辑)
    ↓
[NMS 3×3]       延迟:1 级流水 + 2 行 Line Buffer
    ↓
[双阈值]        延迟:1 级流水
    ↓
输出边缘图(AXI4-Stream)

总流水线延迟:约 9 个时钟周期 + 8 行 Line Buffer
总 BRAM36 消耗:8 个(Gaussian 4 条 + Sobel 2 条 + NMS 2 条)
总 LUT 消耗:约 800~1200 个
总 DSP48 消耗:2 个(Gaussian 除法)

注意:Hysteresis(边缘连接)步骤需要帧缓存,
通常在 PS 端(ARM)或独立帧处理模块中完成,
不在流式流水线中实现。

2.4 直方图统计

2.4.1 算法描述与 FPGA 挑战

直方图统计计算图像中每个灰度级的像素数量:

复制代码
hist[v] = Σ (I(y,x) == v),v ∈ [0, 255]

输出:256 个计数值,每个计数值最大为 1920×1080 = 2,073,600
需要位宽:ceil(log2(2073600)) = 21bit

FPGA 实现的核心挑战是读-改-写(Read-Modify-Write)冲突

复制代码
每个像素到达时,需要:
  1. 读取 hist[pixel_value](当前计数)
  2. 加 1
  3. 写回 hist[pixel_value]

问题:如果连续两个像素的灰度值相同(例如连续两个像素都是 128),
  时钟 0:读取 hist[128] = N,准备写入 N+1
  时钟 1:读取 hist[128] = N(写入还未完成!),准备写入 N+1
  时钟 2:写入 N+1(时钟 0 的结果)
  时钟 3:写入 N+1(时钟 1 的结果,覆盖了时钟 0 的结果!)
  最终 hist[128] = N+1,而不是 N+2,计数错误!

2.4.2 解决方案一:流水线旁路(Bypass)

复制代码
// 检测连续相同地址,直接旁路 BRAM 读取
module histogram (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        i_valid,
    input  wire [7:0]  i_pixel,
    input  wire        i_frame_start,  // 帧开始,清零直方图
    // 读取接口(帧结束后读取结果)
    input  wire [7:0]  rd_addr,
    output wire [20:0] rd_data
);

// 直方图存储(BRAM,256 × 21bit)
(* ram_style = "block" *) reg [20:0] hist_mem [0:255];

// 流水线寄存器
reg [7:0]  addr_d1, addr_d2;
reg [20:0] data_d1, data_d2;
reg        valid_d1, valid_d2;

// 旁路逻辑
wire bypass_d1 = valid_d1 && (addr_d1 == i_pixel);
wire bypass_d2 = valid_d2 && (addr_d2 == i_pixel);

// BRAM 读取(1 个周期延迟)
reg [20:0] bram_dout;
always @(posedge clk)
    bram_dout <= hist_mem[i_pixel];

// 选择正确的读取值(旁路或 BRAM)
wire [20:0] current_val =
    bypass_d1 ? data_d1 :   // 最近 1 个周期有相同地址
    bypass_d2 ? data_d2 :   // 最近 2 个周期有相同地址
    bram_dout;               // 无冲突,使用 BRAM 读取值

// 写回(加 1)
always @(posedge clk)