从零开始,在 6,272 个逻辑单元里塞进一个完整的摄像头采集 -> 边缘检测 -> HDMI 输出的图像流水线。


一、为什么要做这个?

2026 年,FPGA 开发板的门槛已经很低了。小梅哥 AC620 V2,基于 Altera Cyclone IV EP4CE6,一百多块就能买到。但问题来了:

EP4CE6 只有 6,272 个 LEs、30 个 9-bit 乘法器,连一个 SDRAM 控制器都能吃掉 40% 的逻辑资源。而大部分 FPGA 教程上的图像处理例程,都默认你有一块带 DDR 的 SoC 开发板。

这个项目的目标很明确:

在资源极度受限的入门级 FPGA 上,实现完整的实时边缘检测流水线,并且一个 DSP 乘法器都不碰。

二、硬件平台

器件 型号 关键参数
FPGA 小梅哥 AC620 V2 EP4CE6F17C8, 6,272 LEs, 270Kb RAM
摄像头 OV7725 DVP 8-bit, 640x480@30fps, RGB565
输出接口 HDMI (TMDS) 640x480@60Hz
开发工具 Quartus II 13.0 Cyclone IV E 系列

三、架构全景

整个系统的数据流:

四级流水线:

第1级: rgb2gray       - RGB565 转 8-bit 灰度, 1 cycle
第2级: line_buffer_3x3 - 双行 RAM + 移位寄存器, ~2行延迟
第3级: sobel_3x3      - 3x3 Sobel 梯度计算, 1 cycle
第4级: threshold      - 阈值二值化, 1 cycle

总延迟: ~67 cycles | 理论吞吐: 162 FPS (瓶颈在摄像头 30fps)

四、三个关键设计决策

决策 1:FIFO 直通 vs SDRAM 帧缓冲

方案 LE 消耗 复杂度
SDRAM 帧缓冲 ~2,500 LEs (40%) 高(控制器+仲裁)
FIFO 直通 ~200 LEs (3%) 低(单 DCFIFO)

EP4CE6 放一个 SDRAM 控制器基本就告别图像处理了。FIFO 直通方案利用了一个观察:

DVP 写入速度约 24MHz,VGA 读出速度约 25.175MHz,两者几乎相等。1024x16bit 的 DCFIFO 足够缓冲一行像素。

每帧开始时 fifo_aclr 清空 FIFO,保证帧同步,不需要帧缓冲。

决策 2:Sobel vs Canny

算法 乘法器 流水线复杂度 边缘效果
Sobel 0 4 级 清晰,对噪声有抑制
Canny 多个 7+ 级(高斯+NMS+双阈值+连通) 最优但放不下
Laplacian 0 3 级 对噪声极敏感

EP4CE6 只有 15 个 9-bit DSP,Canny 的高斯滤波就要吃掉一半。Sobel 是资源-效果平衡的唯一选择。

决策 3:640x480 vs 更大分辨率

OV7725 原生输出就是 640x480。强行缩放只会浪费 LE。VGA 分辨率对于边缘检测演示完全足够,且 HDMI 640x480@60Hz 恰好是标准时序。

五、模块详解:零乘法器怎么做到的?

5.1 rgb2gray -- 移位加法替代乘法

BT.601 标准灰度公式:

Y = (R x 77 + G x 150 + B x 29) / 256

用移位加法展开:

// R*77  = R*(64+8+4+1)   = R<<6 + R<<3 + R<<2 + R
// G*150 = G*(128+16+4+2) = G<<7 + G<<4 + G<<2 + G<<1
// B*29  = B*(16+8+4+1)   = B<<4 + B<<3 + B<<2 + B

wire [14:0] r_weight = (r << 6) + (r << 3) + (r << 2) + r;
wire [15:0] g_weight = (g << 7) + (g << 4) + (g << 2) + (g << 1);
wire [12:0] b_weight = (b << 4) + (b << 3) + (b << 2) + b;

wire [16:0] lum_sum = r_weight + g_weight + b_weight;
wire [7:0]  gray    = lum_sum[15:8];  // 除以 256 = 取高 8 位

零个乘法器,一个 cycle 出结果。

5.2 line_buffer_3x3 -- 双行 RAM + 移位寄存器

3x3 滑动窗口是整个 Pipeline 中最复杂的部分。设计方案:

        col_cnt ->
    +----+----+----+----+-----
    | P0 | P1 | P2 | P3 | ...
    +----+----+----+----+-----

  line0 RAM -> 上一行的像素缓存 (640x8bit)
  line1 RAM -> 再上一行的像素缓存

  当前行 -> 移位寄存器 [P_cur, P_prev1, P_prev2]

  输出 3x3 窗口:
  w00=line1[col]  w01=line1[col-1]  w02=line1[col-2]
  w10=line0[col]  w11=line0[col-1]  w12=line0[col-2]
  w20=shift[0]    w21=shift[1]      w22=shift[2]

双行 RAM 在 AC620 上被 Quartus 自动推断为 M9K Block RAM,不消耗 LE。

line_sel 信号交替标记当前写入的行,避免复杂的指针管理:

// 写入当前行
if (!line_sel)
    line0[col_cnt] <= gray_in;
else
    line1[col_cnt] <= gray_in;

// 行尾切换
if (col_cnt == IMG_WIDTH - 1) begin
    col_cnt  <= 0;
    line_sel <= ~line_sel;
end

5.3 sobel_3x3 -- 核心亮点

标准 Sobel 核:

Gx = | -w00 + w02 - 2*w10 + 2*w12 - w20 + w22 |
Gy = | -w00 - 2*w01 - w02 + w20 + 2*w21 + w22 |
G  = sqrt(Gx^2 + Gy^2)  ->  近似为 |Gx| + |Gy| (L1 范数)

两个核心技巧:

  1. 乘以 2 = 左移 1 位 -- 标准 Sobel 核里唯一的乘法用移位替代
  2. L1 范数替代 L2 范数 -- sqrt(Gx^2 + Gy^2) 需要乘法器和开方 IP,|Gx| + |Gy| 只需要加法器和取绝对值(符号位判断)
// Gx = (w02 + 2*w12 + w22) - (w00 + 2*w10 + w20)
wire [9:0] gx_pos = w02 + (w12 << 1) + w22;
wire [9:0] gx_neg = w00 + (w10 << 1) + w20;
wire [9:0] gx_raw = gx_pos - gx_neg;
wire [9:0] gx_abs = (gx_raw[9]) ? (~gx_raw + 1'b1) : gx_raw;

// Gy = (w20 + 2*w21 + w22) - (w00 + 2*w01 + w02)
wire [9:0] gy_pos = w20 + (w21 << 1) + w22;
wire [9:0] gy_neg = w00 + (w01 << 1) + w02;
wire [9:0] gy_raw = gy_pos - gy_neg;
wire [9:0] gy_abs = (gy_raw[9]) ? (~gy_raw + 1'b1) : gy_raw;

// L1 norm: G = |Gx| + |Gy|
wire [10:0] grad = gx_abs + gy_abs;

梯度范围 0~2040(255x4 + 255x4 = 2040),占用 11bit。取绝对值用符号位判断 ~x + 1,比调用 LPM_ABS IP 更简洁。

5.4 threshold -- 阈值二值化

binary_out <= (grad_in > {3'd0, thresh}) ? 8'd255 : 8'd0;

默认阈值 64(可配置),11bit 梯度与 8bit 阈值对齐。后续可以升级为 Otsu 自适应阈值。

5.5 top -- 流水线串联

四级流水通过 pixel_valid 信号精确对齐:

rgb2gray (.pixel_valid_in(data_valid), .pixel_valid_out(gray_valid));
line_buf (.pixel_valid_in(gray_valid),  .pixel_valid_out(window_valid));
sobel    (.pixel_valid_in(window_valid), .pixel_valid_out(grad_valid));
threshold(.pixel_valid_in(grad_valid),   .pixel_valid_out(final_valid));

每一级的 valid 信号精确传递,保证流水线各级数据对齐。不需要复杂的握手协议。

六、仿真验证

6.1 测试环境

配置项
仿真工具 Icarus Verilog 12.0 + Python 3.10 (参考对比)
测试图像 32x32 像素,5 种边缘模式
阈值 64/255
时钟 50MHz

6.2 测试图像设计

+------------+------------+
|  垂直边界   |  水平边界   |  Row 0~15
|  (V-edge)  |  (H-edge)  |
+------------+------------+
|  45度对角线 |  L形拐角    |  Row 16~31
|  (Diag)    |  (L-corn)  |
+------------+------------+
     中心: 十字交叉线

这张 32x32 测试图覆盖了图像处理中最常见的五种边缘形态,比单条水平线或垂直线更能暴露流水线 bug。

6.3 仿真结果

区域 总像素 边缘像素 边缘占比 判定
A. 垂直边界 225 6 2.7% 通过
B. 水平边界 225 4 1.8% 通过
C. 对角线 225 28 12.4% 通过
D. L形拐角 225 35 15.6% 通过
总计 1024 79 7.7% 通过

Python/SciPy 参考结果与 Verilog 仿真输出一致,Pipeline 工作正常。

6.4 仿真运行日志节选

Time=1590000ns  valid_out=1  binary=000  gray=000  <- 平坦区
Time=5710000ns  valid_out=1  binary=255  gray=255  <- 边缘检测到
Time=5750000ns  valid_out=1  binary=000  gray=000  <- 平坦区

binary=255 即边缘检测成功。

七、资源消耗实测

Quartus 13.0 Full Compilation 完整编译结果:

Fitter Status : Successful
Device        : EP4CE6F17C8
Family        : Cyclone IV E
资源 总量 已用 占用率
Logic Elements 6,272 923 15%
Combinational Functions 6,272 729 12%
Dedicated Registers 6,272 521 8%
Memory Bits 276,480 30,720 11%
Embedded Multipliers 30 0 0%
PLLs 2 2 100%
Pins 180 26 14%

解读

  • 923 个 LEs (15%) -- 剩下 85% 的资源,足够加直方图均衡、Gamma 校正、甚至第二个视频通道
  • 0 个 DSP 乘法器 -- Sobel Pipeline 本身一个乘法器都没碰
  • 11% Memory Bits -- 行缓冲用了 4 个 M9K (2行 x 640x8bit),剩余 RAM 非常充裕
  • 2/2 PLL -- 全部用完,一个给系统时钟 (50M+24M),一个给 HDMI (25.175M+125.875M)

方案对比

方案 LEs DSP Memory
SDRAM + Canny (估算) ~5,000+ ~12 ~200Kb
SDRAM + Sobel (估算) ~3,300 0 ~100Kb
本设计: FIFO + Sobel 923 0 30Kb

用 SDRAM 方案做 Sobel 是别人两倍的资源消耗,做 Canny 直接放不下。本设计用纯粹的数据流思路,把入门级 FPGA 的每一份资源用到了刀刃上。

八、写在最后

这个项目教会我的

  1. "能跑"和"用最少资源跑"是两回事。把 Sobel 跑起来,CS 专业的学生用 Python 加 OpenCV 五行代码就行。但在一个只有 6,272 LEs 的芯片上,把摄像头采集、跨时钟域、流水线处理、HDMI 输出全塞进去,且一个乘法器不动 -- 这才是 FPGA 工程师的价值。

  2. 移位操作是 FPGA 世界里最便宜的东西。乘以 2 = <<1,除以 256 = 取高位。掌握了这些基本功,很多复杂运算都能化简。

  3. 仿真先行。用 Python 生成测试图 -> Icarus Verilog 仿真 -> Python 对比验证,这条链路跑通后,上板只是最后一步。

Logo

CANN开发者社区旨在汇聚广大开发者,围绕CANN架构重构、算子开发、部署应用优化等核心方向,展开深度交流与思想碰撞,携手共同促进CANN开放生态突破!

更多推荐