基于 FPGA 的 CLAHE 自适应限制对比度直方图均衡算法硬件 Verilog 实现
一、CLAHE 算法基本原理
1.1 算法背景
CLAHE(Contrast Limited Adaptive Histogram Equalization,对比度受限的自适应直方图均衡)是对传统自适应直方图均衡(AHE)的改进。AHE 通过将图像划分为多个子区域(称为 Tiles),对每个 Tile 独立进行直方图均衡化,从而适应图像的局部特性。然而,AHE 在噪声较大的平坦区域(如天空、墙面)容易过度放大噪声,产生伪影。
CLAHE 通过引入对比度限制机制来解决此问题。
![CLAHE 算法原理示意图]
1.2 核心处理步骤
1.2.1 图像分块 (Tiling)
将整幅图像划分为 M × N 个连续且不重叠的矩形子区域(Tiles)。本设计采用 4×4=16 分块。
1.2.2 直方图计算 (Histogram Calculation)
为每个 Tile 独立计算其灰度直方图 H(i),其中 i 是灰度级(0-255)。
1.2.3 对比度限制 (Contrast Limiting / Clipping)
这是 CLAHE 的关键步骤。首先设定"裁剪阈值"(Clip Limit),根据归一化的裁剪因子 β、Tile 总像素数 N_tile 和灰度级数 L 计算:
T_clip = β × N_tile / L
遍历 Tile 直方图,将超出阈值的像素数裁剪:
H_clipped(i) = T_clip if H(i) > T_clip; H(i) if H(i) ≤ T_clip
1.2.4 溢出重分配 (Redistribution)
将所有灰度级裁剪下来的像素总数(溢出量)均匀重分配到所有灰度级中:
N_overflow = Σ max(0, H(i) - T_clip)
H_final(i) = H_clipped(i) + N_overflow / L
硬件实现优化:由于 RTL 使用整数统计直方图,同时需要保证直方图总和不变,这里我们采用整除 + 余数分配策略:
avg = floor(N_overflow / L), remainder = N_overflow mod L
H_final(i) = H_clipped(i) + avg + 1 if i < remainder; H_clipped(i) + avg if i ≥ remainder
1.2.5 生成映射函数 (Mapping Function)
对于每个块(tile),基于处理后的直方图计算累积分布函数(CDF),归一化后作为映射查找表,即输入像素 h 灰度值为 j,映射后输出灰度值为 LUT(j)
CDF(j) = Σ H_final(i)
LUT(j) = (L-1) / N_tile × CDF(j)
1.2.6 双线性插值 (Bilinear Interpolation)
为消除 Tile 边界的"块效应",每个像素的输出值通过查找周围四个 Tile 中心的 LUT 映射值 V,再进行双线性插值加权平均得出:
V_top = (1 - Δx) · V_TL + Δx · V_TR
V_bottom = (1 - Δx) · V_BL + Δx · V_BR
P_out = (1 - Δy) · V_top + Δy · V_bottom
二、硬件架构设计
2.1 顶层模块架构
![顶层模块架构图]
顶层模块 clahe_top 负责整个 CLAHE 系统的集成和协调,管理各子模块间的数据流和控制流。主要包含以下子模块:
| 模块名称 | 功能描述 |
|---|
clahe_coord_counter | 坐标计数与 Tile 定位 |
clahe_histogram_stat | 直方图实时统计 |
clahe_clipper_cdf | 对比度限制与 CDF 计算 |
clahe_mapping_parallel | 双线性插值映射输出 |
clahe_ram_16tiles_parallel | 32 块 RAM 乒乓管理 |
因为所有 tile 的直方图统计在一帧输入结束后才统计完成,所以我们在帧间隙进行逐个 tile 的 CDF 计算和 LUT 生成。使用乒乓操作,一组 ram 用于统计当前输入帧的直方图数据,一组 ram 保存上一帧帧间隙中计算得到的查找表,帧开始的 vsync 上升沿二者切换,实现对视频输入的实时处理。
乒乓控制逻辑:在 CDF 计算完成时切换 ping_pong_flag,充分利用帧间隙时间,确保下一帧 VSYNC 上升沿来临前,乒乓切换已完成:
// 乒乓切换:在 CDF 完成时切换
always @(posedge pclk or negedge rst_n) begin
if (!rst_n) begin
ping_pong_flag <= 1'b0;
end else if (cdf_done_posedge) begin
// 优化:在 CDF 完成时立即切换 ping_pong
// 此时 CDF LUT 已经完全写入 RAM,可以安全切换
ping_pong_flag <= !ping_pong_flag;
end
end
2.2 坐标计数器模块 (clahe_coord_counter)
该模块实时计算输入像素的全局坐标、所属 Tile 索引和 Tile 内相对坐标,为直方图统计和像素映射提供位置信息。
设计要点:
- 在
href 有效期间递增横向坐标 x_cnt,行结束时递增纵向坐标 y_cnt
- 使用比较器链代替除法器计算 Tile 索引(节省资源)
- 块内坐标使用移位加法计算,减少资源使用
Tile 索引计算原理:
// 横向 tile 索引计算(x_cnt 除以 320)
// 通过比较 x_cnt 的范围来确定 tile_x 的值
always @(*) begin
if (x_cnt < 320) // 0-319 像素 -> tile 0
tile_x = 2'd0;
else if (x_cnt < 640) // 320-639 像素 -> tile 1
tile_x = 2'd1;
else if (x_cnt < 960) // 640-959 像素 -> tile 2
tile_x = 2'd2;
else // 960-1279 像素 -> tile 3
tile_x = 2'd3;
end
// tile 总索引:使用位拼接 {tile_y, tile_x} 等价于 tile_y*4 + tile_x
tile_idx = {tile_y, tile_x}; // 4 位 tile 索引,范围 0-15
块内坐标优化计算(使用移位替代乘法):
// 横向偏移量计算:tile_x * 320 = tile_x * (256 + 64)
// = (tile_x << 8) + (tile_x << 6)
wire [10:0] tile_x_offset;
assign tile_x_offset = ({tile_x, 8'd0}) + ({tile_x, 6'd0});
// 纵向偏移量计算:tile_y * 180 = tile_y * (128 + 32 + 16 + 4)
// = (tile_y << 7) + (tile_y << 5) + (tile_y << 4) + (tile_y << 2)
wire [9:0] tile_y_offset;
assign tile_y_offset = ({tile_y, 7'd0}) + ({tile_y, 5'd0}) + ({tile_y, 4'd0}) + ({tile_y, 2'd0});
// 相对坐标 = 全局坐标 - 偏移量
assign local_x = x_cnt[8:0] - tile_x_offset[8:0];
assign local_y = y_cnt[7:0] - tile_y_offset[7:0];
2.3 直方图统计模块 (clahe_histogram_stat)
该模块对每个 Tile 的 256 个灰度级进行实时统计,使用3 级流水线实现读 - 增 - 写操作。由于没有两个端口同时分别进行读写的需求,这里我们使用伪双端口 RAM 即可,节约资源,后续 RAM 控制模块会具体讲到。
![直方图统计模块结构图]
流水线结构:
- Stage 1:输入打拍 + 相邻相同检测
- Stage 2:RAM 读取 + 旁路数据选择
- Stage 3:RAM 写入
2.3.1 读写冲突问题分析
对于流水读写问题,需考虑流水线深度内的数据冲突问题。也就是体系结构中的数据冒险。
对于流水读写 RAM 的情况,极易出现下列情况:
冲突 1:连续相同像素值
例如像素序列:100, 100, 50,对于第二个 100 像素,读取统计旧值时,第一个 100 的累加值尚未写入,导致第二个像素累加值错误。
冲突 2:间隔相同像素值(流水线深度冲突)
例如像素序列:100, 50, 100...(间隔 2 周期,< 流水线深度 3),第二个 100 读取时,第一个 100 正在写入,发生读写冲突。双端口 RAM 在发生读写冲突时存在读数据不可靠的问题(且部分厂家的伪双端口 RRAM 不能配置为写优先或者读优先,实际读取值很可能是 x 不定态),需要进行处理。
2.3.2 冲突解决方案
问题 1 解决方案:检测连续输入的相同像素值,由于后面的像素读取统计值相当于比实际少了 1,我们可以在写入时 +2 弥补。
// Stage 1: 相邻相同检测
always @(posedge pclk or negedge rst_n) begin
if (!rst_n) begin
same_as_prev <= 1'b0;
end else begin
// 检测相邻相同:当前输入与上一周期输入比较
if ((in_href && in_vsync && clear_done) && valid_s1 && (in_y == pixel_s1) && (tile_idx == tile_s1)) begin
same_as_prev <= 1'b1;
end else begin
same_as_prev <= 1'b0;
end
end
end
// Stage 2: 设置增量:相邻相同 +2,否则 +1
if (same_as_prev) begin
increment_s2 <= 2'd2;
end else begin
increment_s2 <= 2'd1;
end
问题 2 解决方案:使用旁路逻辑解决读写冲突。若当前周期发生写地址与读地址相同,寄存当前写数据作为读取值(相当于强制实现写优先,避免综合后行为和使用的 RAM 行为模型不一致的问题):
// 冲突检测:Stage1 读地址 == Stage3 写地址
wire conflict = (pixel_s1 == pixel_s3) && (tile_s1 == tile_s3) && valid_s3;
always @(posedge pclk or negedge rst_n) begin
if (!rst_n) begin
bypass_valid <= 1'b0;
bypass_data <= 16'd0;
end else begin
if (conflict) begin
bypass_valid <= 1'b1;
bypass_data <= ram_wr_data_s3; // 保存写入的数据
end else begin
bypass_valid <= 1'b0;
end
end
end
// 数据选择:旁路优先
wire [15:0] selected_data = bypass_valid ? bypass_data : ram_rd_data_b;
通过以上两种方法结合,连续三个周期输入像素的情况也可以正确处理(当前输入像素的读取值用正在写入的数据替代,在此基础上 +2 写入,累积写入值正确)。本方法相当于对写回的统计值进行补偿修正,保证写入的统计值完全正确,可以完美解决数据冒险的问题。统计结果没有任何误差。
2.4 对比度限制与 CDF 计算模块 (clahe_clipper_cdf)
该模块在 histogram 结束后(帧间隙期间),对每帧图像 16 个 Tile 的直方图数据进行 Clip 阈值限制裁剪和 CDF 计算,最后归一化生成像素映射查找表。
![对比度限制与 CDF 计算模块流程图]
有限状态机流程:
| 状态 | 周期数 | 说明 |
|---|
| READ_HIST_CLIP | 257 | 读取直方图 + 裁剪 |
| CLIP_REDIST | 257 | 仅在有溢出时执行,重分配溢出值 |
| CALC_CDF | 257 | 累积分布函数计算 |
| WRITE_LUT | 259 | 3 级流水线归一化写入 |
| NEXT_TILE | 1 | Tile 切换 |
| DONE | 1 | 产生 cdf_done 脉冲 |
TODO:写到这里发觉 CLIP_REDIST 应该可以和 CALC_CDF 阶段合并,进一步节约时间提高帧率,后续有时间优化一下
时序分析:
- 每块 Tile 总周期数:约 257 + 257 + 257 + 259 + 1 + 1 = 1032 周期
- 16 块耗时:16 × 1032 = 16512 周期
- 在 96MHz 时钟频率下耗时约 172μs
- 1280×720@30fps 帧间隙约 33ms,CDF 模块处理时间充足
归一化公式(标准 CLAHE 实现):
LUT(j) = (CDF(j) - CDF_min) × 255 / (CDF_max - CDF_min)
2.5 RAM 管理模块 (clahe_ram_16tiles_parallel)
![RAM 管理模块架构图]
该模块负责管理 32 块伪双端口 RAM,实现乒乓操作、四块并行读取和多端口仲裁。帧内 RAM 内的数据作为直方图统计值,帧间隙计算映射值写回该组 RAM,下一帧作为映射 LUT 使用。像素灰度值直接作为读写地址,所以 RAM 深度为 256。
乒乓双组 RAM 架构:
| 帧状态 | RAM_A 组用途 | RAM_B 组用途 |
|---|
| 帧 N (ping_pong_flag=0) | 统计(Port A 写,Port B 读) | 映射(Port B 四块并行只读) |
| 帧 N+1 (ping_pong_flag=1) | 映射(Port B 四块并行只读) | 统计(Port A 写,Port B 读) |
并行读取接口设计:
由于 mapping 模块中的双线性插值需要读取当前像素最近的四个块(Tile)的输出 LUT,为实现全流水,设计了四块并行读取功能:
![并行读取接口设计图]
三、仿真验证
鉴于图像区域每个分块都需要分配一块伪双端口 BRAM,为减少资源占用,Baseline 工程采用 4 × 4 = 16 分块设计。虽然实际输出效果远不如 8 × 8 Tile 版本,但效果优于传统的 HE 算法。
ModelSim 仿真结果:
![ModelSim 仿真波形图]
四、优化方向展望
基础实现版本在面对高分辨率(HD/FHD)和更精细的分块(64-tile)需求时,存在以下挑战:
- 时序瓶颈:组合逻辑过深,关键路径延迟达 35ns+,频率上不去(仅~28MHz)
- 资源消耗大:直接扩展到 64-tile 将消耗大量 BRAM 资源
- RAM 利用率低:每块 RAM 实际容量远小于单 block BRAM 容量,存在浪费
针对这些问题,可以应用 VLSI DSP 信号处理理论中的核心优化技术进行改进:
| 优化技术 | 应用目标 |
|---|
| 割集流水线 (Cut-Set Pipelining) | 切断 CDF 计算中的长组合逻辑路径 |
| 重定时 (Retiming) | 解决深度流水线引入的控制与数据路径对齐问题 |
| 算法强度缩减 (Strength Reduction) | 优化插值运算,减少乘法器使用 |
| 硬件折叠 (Folding) | 巧妙设计地址映射实现 ram 复用 |
通过系统性地应用这些技术,可以大幅提升工作频率并显著降低资源消耗,实现真正的高性能实时视频图像增强方案。
以下是 64-Tile 版本的基础版本与优化版本在 Xilinx 7 系列 FPGA 上的对比数据:
资源消耗对比:
| 资源类型 | Baseline (64t) | Optimized (64t) | 变化幅度 |
|---|
| LUTs (逻辑单元) | 8,014 | 3,738 | ↓ 53.4% |
| Registers (寄存器) | 637 | 3,281 | ↑ 415% |
| Block RAM (Tiles) | 66 | 18 | ↓ 72.7% |
| F7/F8 Muxes | 1,024 | 52 | ↓ 95.0% |
寄存器数量增加是流水线技术"用面积换速度"的体现,符合预期的设计权衡。
时序性能对比:
| 指标 | Baseline @ 74MHz | Optimized @ 100MHz |
|---|
| WNS (最差负裕量) | -22.347 ns (Failed) | +4.704 ns (Met) |
| 理论最高频率 (Fmax) | ~28 MHz | ~188 MHz |
| 关键路径延迟 | 35.5 ns | 5.30 ns |
| 逻辑级数 | 185 级 | 6 级 |
优化后的设计不仅各项时序指标完全满足 1280×720 甚至更高分辨率的实时处理需求,而且在资源效率上达到了极优水平。
五、总结
本文详细介绍了 CLAHE 算法在 FPGA 上的硬件实现方案,包括:
- 算法原理:分块、直方图统计、对比度限制、溢出重分配、CDF 计算、双线性插值
- 模块化架构:坐标计数器、直方图统计、CDF 计算、映射输出、RAM 管理
- 关键设计技巧:
- 比较器链替代除法器计算 Tile 索引
- 移位加法替代乘法计算偏移量
- 3 级流水线处理直方图统计的读写冲突
- 乒乓 RAM 架构实现帧级并行处理
- 四块并行读取支持全流水双线性插值
该设计实现了 1280×720@30fps 的实时处理能力,验证了 CLAHE 算法硬件化的可行性。
参考资料:
- K. K. Parhi, VLSI Digital Signal Processing Systems: Design and Implementation
- Karel Zuiderveld, Contrast Limited Adaptive Histogram Equalization (Graphics Gems IV)