Technical Details
技术细节
这一部分直接围绕工程代码展开,按“采集 → 帧缓存 / 显示 → 预处理 → 输入缓存 → CNN 推理 → 调试闭环”的顺序拆解关键实现。 页面展示的不是完整源码,而是从实际工程中提炼出的核心片段,用于说明本项目从原始 LeNet 硬件工程迁移到 OV5640 视频流实时识别时所做的主要工程化改造。
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 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 灰度背景估计、极性判断与对比度增强
摄像头拍摄手机屏幕或纸面时,可能出现白底黑字、黑底白字、灰度偏淡等情况。工程中先估计识别框角落背景亮度,再自动选择 bg - gray 或 gray - 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]; 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 位置标准化与最终 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)
); 跨时钟域启动:从 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)
); 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); 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
关键问题与调试闭环
灰度极性不统一
通过四角背景估计、自动极性判断、阈值去噪和增益拉伸,将输入统一为黑底亮字。
CNN 层间启动过早
把层间推进条件从 iterator.ready 改为真实 write_done 脉冲,避免下一层读取未写完的输出。
标准化后启动时机错误
将 CNN 启动源从 capture_ready 改为 norm_ready,并通过 CDC 同步到 CNN 时钟域。
src_buf 连接错误
统一串口 dump、Python 验证与 LeNet 实际读取的 RAM,解决图像正常但分类固定的问题。
Optimization
输入标准化与 6 / 9 分析
已验证有效的优化
- 64x64 识别框 -> 2x2 平均下采样 -> 32x32 CNN 输入。
- 自动极性判断,统一黑底亮字输入。
- soft recenter 与目标居中处理。
- 提高阈值并削弱弱连接对闭环数字的干扰。
6 / 9 仍较困难的原因
- 结构依赖闭环和竖笔,形态变化更敏感。
- LeNet 对位置和重心偏移较敏感。
- MNIST 训练分布与摄像头输入分布存在差异。
- 闭环过强时容易向 0 或 8 混淆。
Result
项目成果
Future Work
后续优化方向
当前系统已经具备较完整的实时识别能力,也建立了软硬件对齐的调试基础。后续重点可放在 6 / 9 专项测试、FPGA logits dump、多位置推理投票,以及面向摄像头输入分布的轻量微调。