返回项目档案

FPGA / LeNet / OV5640 / HDMI / EQ6H103P

基于 FPGA 的实时手写数字识别系统移植与优化

项目基于 LulinChen 的 cnn_open / LeNet 结构 github.com/lulinchen/cnn_open/tree/master 进行工程化移植,将原有串口接收识别模式重构为 OV5640 视频流实时识别系统,并完成 Vivado / Xilinx 工程到 eLinx EQ6H103P 平台的适配。

OV5640 实时摄像头输入,输出像素时钟、同步信号与 RGB 数据
SDRAM 视频帧缓存,衔接采集、显示与识别链路
32x32 标准化与居中后的 LeNet 最终输入尺寸
UART 导出 FPGA 实际 CNN 输入,形成软硬件调试闭环

System Diagram

系统总体框图

OV5640 -> SDRAM -> ROI / Normalize -> src_buf -> LeNet -> HDMI / UART

主链路和调试闭环分开表达,避免“标准化之后又回到 LeNet 前面”的方向误读。

FPGA Real-time SDRAM Frame Buffer Verilog Python Golden Model
OV5640 采集
SDRAM 帧缓存
ROI / Gray 裁剪与灰度
Normalize 极性 / 下采样 / 居中
src_buf 32x32 输入缓存
LeNet 定点 CNN 推理
HDMI / UART 显示与验证
01

视频采集

Camera Capture

02

帧缓存

Video Buffer

03

图像预处理

Gray / Polarity / Normalize

04

CNN 推理

src_buf / LeNet

05

输出与验证

HDMI / UART / Python

Technical Details

技术细节

这一部分直接围绕工程代码展开,按“采集 → 帧缓存 / 显示 → 预处理 → 输入缓存 → CNN 推理 → 调试闭环”的顺序拆解关键实现。 页面展示的不是完整源码,而是从实际工程中提炼出的核心片段,用于说明本项目从原始 LeNet 硬件工程迁移到 OV5640 视频流实时识别时所做的主要工程化改造。

01

OV5640 视频采集与像素同步

采集链路负责把摄像头输出的 8-bit 数据重新组合成 RGB565 像素,并裁剪到有效显示区域。为了修正摄像头边沿采样与 FPGA 内部后级逻辑之间的时序关系,工程中采用了“下降沿采样、上升沿桥接”的方式。

  • 使用 vsync 边沿检测 判断新帧开始,并丢弃前 10 帧,避免摄像头初始化后图像抖动。
  • 通过 data_flag 合并两个 8-bit 摄像头数据,形成 16-bit RGB565 像素。
  • 将下降沿域中的干净像素信号打一拍同步到上升沿域,供 SDRAM 和后级视频链路稳定读取。
// cam_capture.v:下降沿采样,再桥接到上升沿内部逻辑
wire [15:0] pixel_data_neg  = pic_valid ? data_out_reg   : 16'd0;
wire        pixel_valid_neg = pic_valid ? data_flag_dly1 : 1'b0;

always @(posedge cam_pclk or negedge sys_rst_n) begin
  if (!sys_rst_n) begin
    pixel_data_pos  <= 16'd0;
    pixel_valid_pos <= 1'b0;
  end else begin
    pixel_data_pos  <= pixel_data_neg;
    pixel_valid_pos <= pixel_valid_neg;
  end
end
02

SDRAM / 行缓存:把视频流和显示读取解耦

视频输入和 HDMI 显示不处于同一节拍。工程中使用帧缓存和行级 bank 切换,让摄像头写入、HDMI 读取和识别区域取样不直接互相阻塞。这个环节是把原本偏静态输入的 CNN 工程改造成实时视频系统的关键。

  • 写端在 cam_pclk 下随 pixel_valid 写入,行结束时翻转 bank 和 line_toggle。
  • 读端在 video_clk 下检测 line_toggle 的变化,并只在 HDMI 新行开始时切换读取 bank。
  • 避免在一行显示过程中强行切换 bank,从而减少撕裂、错行和显示跳变。
// cam_frame_buf.v:写端行结束后翻转,读端在新行开始时安全切换
if (href_fall) begin
  wr_bank      <= ~wr_bank;
  wr_addr      <= 10'd0;
  line_toggle  <= ~line_toggle;
  buf_ready_wr <= 1'b1;
end

wire new_line_rdy = (lt_sync[2] ^ lt_sync[1]);

if (rd_en && !rd_en_d) begin
  rd_addr <= 10'd0;
  if (pending_bank_switch) begin
    rd_bank <= ~wr_bank;
    pending_bank_switch <= 1'b0;
  end
end
03

灰度背景估计、极性判断与对比度增强

摄像头拍摄手机屏幕或纸面时,可能出现白底黑字、黑底白字、灰度偏淡等情况。工程中先估计识别框角落背景亮度,再自动选择 bg - graygray - bg,最终统一成 LeNet 更容易识别的“黑底亮字”。

  • 从 ROI 角落采样背景亮度,使用平均值作为 cnn_bg_level
  • 根据背景阈值自动判断是白底还是黑底,也保留手动指定极性的调试模式。
  • 对差分结果做噪声阈值抑制和增益放大,最后饱和到 8-bit 灰度输入。
// hdmi_ctrl.v:自动极性 + 去噪 + 增益拉伸
wire cnn_bg_is_white_auto = (cnn_bg_level >= CNN_BG_WHITE_THR);

wire cnn_use_white_bg =
  (CNN_POLARITY_MODE == 2'd1) ? 1'b1 :
  (CNN_POLARITY_MODE == 2'd2) ? 1'b0 :
                                  cnn_bg_is_white_auto;

wire [8:0] cnn_diff_raw =
  cnn_use_white_bg ? cnn_diff_white_bg : cnn_diff_black_bg;

wire [8:0]  cnn_diff_denoised =
  (cnn_diff_raw > {1'b0, CNN_NOISE_THR}) ?
  (cnn_diff_raw - {1'b0, CNN_NOISE_THR}) : 9'd0;

wire [7:0] cnn_input_pixel =
  (cnn_amp > 12'd255) ? 8'd255 : cnn_amp[7:0];
04

64×64 ROI 到 32×32 LeNet 输入

直接使用 32×32 识别框时,手写数字容易因为摄像头距离和笔画粗细变化而丢失结构。工程改为捕获 64×64 ROI,并使用 2×2 平均下采样生成 32×32 输入,尽量保留 6 / 9 等闭环数字的几何特征。

  • 用 rel_x / rel_y 计算 ROI 内相对坐标,再由低位判断 2×2 block 的左 / 右 / 下半部分。
  • 通过 line_buf 保存上一行像素,得到 top-left、top-right、bottom-left、bottom-right 四点。
  • 在 bottom-right 到达时写出平均结果,地址范围 0~1023,写满后产生 capture_ready。
// capture_lenet_scaled2x.v:2×2 平均下采样,输出 1024 个像素
wire block_right = in_roi && (rel_x6[0] == 1'b1);
wire block_bot   = in_roi && (rel_y6[0] == 1'b1);

wire [9:0] avg_sum =
  {2'd0, top_left_hold} + {2'd0, prev_row_px} +
  {2'd0, bot_left_hold} + {2'd0, cam_data_i};

wire [`W1:0] avg_px = (avg_sum + 10'd2) >> 2;

if (block_right && block_bot) begin
  ab_frame_buf   <= out_addr;
  db_frame_buf   <= avg_px;
  cenb_frame_buf <= 1'b0;
  if (out_addr == 10'd1023) capture_ready <= 1'b1;
end
05

位置标准化与最终 src_buf 输入

下采样后的图像不会直接启动 CNN,而是先进入 mnist_recenter32。该模块根据前景阈值寻找数字重心,将图像移动到目标中心附近,再写入最终 src_buf。这样可以减少手写数字在识别框内位置偏移带来的误判。

  • src_buf 的写端来自 norm_src_we / norm_src_addr / norm_src_data,读端由 LeNet 控制。
  • LeNet 实际读取的是标准化后的 src_buf,而不是 capture_lenet 的原始输出。
  • UART dump、Python golden model 和 CNN 读取路径都围绕同一最终输入缓存对齐。
// hdmi_ctrl.v:标准化输出写入最终 src_buf,LeNet 从同一 RAM 读取
rfdp1024x8 src_buf(
  .CLKA(cnn_clk),   .CENA(cena_src_buf),
  .AA(aa_src_buf),   .QA(qa_src_buf),

  .CLKB(video_clk), .CENB(~norm_src_we),
  .AB(norm_src_addr), .DB(norm_src_data[7:0])
);

mnist_recenter32 #(
  .FG_THR(8'd24), .COPY_THR(8'd8),
  .TARGET_X(7'sd17), .TARGET_Y(7'sd18),
  .MAX_SHIFT(7'sd8)
) u_mnist_recenter32(
  .cap_done(capture_ready),
  .src_we(norm_src_we),
  .src_addr(norm_src_addr),
  .src_data(norm_src_data),
  .norm_ready(norm_ready)
);
06

跨时钟域启动:从 capture_ready 改为 norm_ready

加入 recenter 后,capture_ready 只代表原始裁剪 / 下采样完成,并不代表最终 src_buf 已经写完。如果仍用 capture_ready 启动 CNN,就可能提前读取旧数据或未完成数据。因此启动源改为 norm_ready,并通过 toggle 方式跨时钟域同步。

  • video_clk 域中的 norm_ready 只作为事件触发,先翻转 flip_i。
  • cnn_clk 域中用两级寄存器同步 flip_i,并检测翻转边沿生成单周期 lenet_go。
  • 这使 CNN 启动时刻严格落在最终 src_buf 写完之后。
// misc.v:toggle 型 CDC,将 norm_ready 转成 CNN 时钟域单周期 go
always @(posedge clk_i or negedge rstn_i)
  if (!rstn_i) flip_i <= 0;
  else if (go_i) flip_i <= ~flip_i;

assign go_o = (flip_o_p1 != flip_o);

go_CDC_go u_go_CDC_go(
  .clk_i(video_clk),
  .go_i(norm_ready),
  .clk_o(cnn_clk),
  .go_o(lenet_go)
);
07

LeNet 层间推进:用真实 write_done 替代 ready 误触发

原工程中部分层级容易把 iterator.ready 当作下一层启动条件,但 ready 只表示地址遍历结束,不一定等于输出 buffer 已经完成写入。项目调试后改为使用各层真实写完 buffer 的 done 脉冲推进下一层,避免后续层读取未写完或被覆盖的数据。

  • Conv1 写入 28×28 输出后,以 c1_write_done 启动 Pool1 / ReLU1。
  • Conv2 写入 10×10 输出后,以 c2_write_done 启动 Pool2 / ReLU2。
  • FC1 / FC2 同样使用写完判断推进,保证硬件执行顺序更接近 Python 逐层前向模型。
// lenet.v:用写地址到达末尾 + q_en 作为真实写完脉冲
wire c1_write_done =
  q_conv1_en && (ab_conv1_buf == 10'd783);

iterator #(
  .KERNEL_SIZEX(2), .KERNEL_SIZEY(2),
  .STEP(2), .INPUT_WIDTH(28), .INPUT_HEIGHT(28)
) it_p1(
  .clk(clk), .rstn(rstn),
  .go(c1_write_done),
  .ready(p1_ready)
);

wire c2_write_done =
  q_c2_en && (ab_c2_buf == 7'd99);

wire fc1_write_done =
  qa_r_fc1_en && (ab_fc1_buf == 7'd119);
08

RAM / ROM 适配与 Python 对照验证

为了适配 eLinx / EQ6H103P 工程环境,项目将原本依赖特定 FPGA IP 的 RAM / ROM 改成可复用的自定义模块,并用 hex 文件加载 CNN 权重。这样 FPGA 与 Python 验证脚本可以复用同一组权重文件,方便对齐 logits 和分类结果。

  • 双口 RAM 统一为 generic_dpram 包装,便于替换不同深度和位宽的特征图缓存。
  • 权重 ROM 通过 $readmemh 加载外部 hex 文件,便于和 Python 端保持参数一致。
  • UART 导出的最终 32×32 输入图像可直接送入 Python golden model,用于判断问题来自预处理还是 CNN 本体。
// lenet_ram_blocks.v / lenet_roms.v:自定义 RAM 与 hex 权重加载
module rfdp1024x8(
  input wire CLKA, input wire CENA,
  input wire [9:0] AA, output wire [7:0] QA,
  input wire CLKB, input wire CENB,
  input wire [9:0] AB, input wire [7:0] DB
);
  generic_dpram #(.DATA_WIDTH(8), .ADDR_WIDTH(10), .DEPTH(1024)) u_ram(...);
endmodule

module wieght_conv1_rom(input clk, input [9:0] aa, output reg [95:0] qa);
  reg [95:0] mem [0:24];
  initial $readmemh("wieght_conv1_rom.hex", mem);
  always @(posedge clk) if(!cena) qa <= mem[aa];
endmodule

Modules

硬件与工程组成

cam_capture

摄像头输入采集、像素同步与有效区域输出。

sdram_ctrl

视频帧缓存,衔接 OV5640、识别链路与 HDMI。

hdmi_ctrl

视频主控、OSD 叠加、识别框绘制和 UART 调试协调。

capture_lenet / capture_lenet_scaled2x

截取识别区域,并组织 64x64 到 32x32 的 CNN 输入图像。

mnist_recenter32

32x32 图像位置标准化与重心调整。

src_buf

标准化后最终 CNN 输入缓存。

lenet / cnn.v

LeNet 顶层控制、卷积、池化、ReLU、MAC 与层间启动时序。

lenet_roms / ram_blocks

权重 ROM、自定义 RAM 与 eLinx 平台适配。

go_CDC_go

跨时钟域启动脉冲同步。

digit_osd / draw_rectangle

显示识别框与最终分类结果。

Debugging

关键问题与调试闭环

1

灰度极性不统一

通过四角背景估计、自动极性判断、阈值去噪和增益拉伸,将输入统一为黑底亮字。

2

CNN 层间启动过早

把层间推进条件从 iterator.ready 改为真实 write_done 脉冲,避免下一层读取未写完的输出。

3

标准化后启动时机错误

将 CNN 启动源从 capture_ready 改为 norm_ready,并通过 CDC 同步到 CNN 时钟域。

4

src_buf 连接错误

统一串口 dump、Python 验证与 LeNet 实际读取的 RAM,解决图像正常但分类固定的问题。

Optimization

输入标准化与 6 / 9 分析

已验证有效的优化

  • 64x64 识别框 -> 2x2 平均下采样 -> 32x32 CNN 输入。
  • 自动极性判断,统一黑底亮字输入。
  • soft recenter 与目标居中处理。
  • 提高阈值并削弱弱连接对闭环数字的干扰。

6 / 9 仍较困难的原因

  • 结构依赖闭环和竖笔,形态变化更敏感。
  • LeNet 对位置和重心偏移较敏感。
  • MNIST 训练分布与摄像头输入分布存在差异。
  • 闭环过强时容易向 0 或 8 混淆。

Result

项目成果

完成 LeNet 手写数字识别网络到 EQ6H103P FPGA 工程的移植。
以自定义 RAM / ROM 结构替代原工程依赖的特定 IP。
实现 OV5640 -> SDRAM -> 预处理 -> src_buf -> LeNet -> HDMI / UART 的实时链路。
建立 Python 硬件级 CNN 验证工具,形成可复现的调试闭环。
完成 64x64 识别框、32x32 下采样、极性归一化和重心居中优化。
实现实时识别框显示和数字分类结果叠加输出。

Future Work

后续优化方向

当前系统已经具备较完整的实时识别能力,也建立了软硬件对齐的调试基础。后续重点可放在 6 / 9 专项测试、FPGA logits dump、多位置推理投票,以及面向摄像头输入分布的轻量微调。

回到框图