
1. 项目缘起与整体设计思路1.1 为什么要在PYNQ-Z2上折腾YOLOv2硬件加速YOLOv2 作为一个经典的单阶段目标检测网络在 2017 年提出来的时候主打的就是“快”。它把检测问题直接建模成回归问题一次前向传播就能输出所有目标的边界框和类别不像两阶段检测那样需要单独的区域建议网络。这个特性让它在嵌入式端特别有吸引力——因为嵌入式场景最怕的就是延迟不可控。但问题也很直接YOLOv2 的骨干网络是 Darknet-1919 层卷积加若干池化参数量大概 5000 万级别浮点运算量在几十 GFLOPs 量级。如果直接在 PYNQ-Z2 的 ARM Cortex-A9 上跑纯软件推理一帧 416×416 的图几秒甚至十几秒才能出结果完全没法用。所以思路就变成了把计算最密集的卷积层丢给 FPGA 做硬件加速ARM 只负责调度、预处理和后处理。PYNQ-Z2 这块板子恰好适合干这个事——Zynq-7020 芯片里有 220 个 DSP48E1 切片、4.9Mb BRAM、以及 85K 逻辑单元虽然不算富裕但跑一个精简版 YOLOv2 的卷积加速是够用的。更重要的是 PYNQ 框架把 Jupyter Notebook 直接搬到了板子上你可以在浏览器里写 Python 调用 FPGA 的 overlay调试效率比传统 Vivado SDK 那种“编译-下载-看串口”的循环高太多了。这个项目的核心目标就是用 Vivado HLS 把 YOLOv2 的卷积层综合成硬件 IP通过 PYNQ overlay 加载到 FPGA 上然后在 Jupyter Notebook 里用 Python 完成从图像输入到检测结果输出的完整流程。适合有数字电路基础、想入门 FPGA 加速的开发者也适合做嵌入式 AI 部署的工程师参考。1.2 整体架构怎么切分软硬件软硬件划分是这个项目最关键的设计决策。我当时的划分原则很简单计算密集且规则的部分给 FPGA控制密集且不规则的部分留给 ARM。具体来说YOLOv2 的计算量分布大概是这样的卷积层占了 99% 以上的乘加运算池化层和 BN 层计算量小但访存频繁而最后的区域解码、非极大值抑制NMS这些后处理逻辑复杂、分支多不适合硬件流水线。所以最终的架构是PL 端FPGA负责卷积层、BN 融合后的乘加、LeakyReLU 激活、最大池化。这些操作数据流规整可以做成流水线。PS 端ARM负责图像预处理缩放、归一化、层间调度、内存管理、后处理解码边界框、NMS、绘制结果。数据交互通过 AXI4 总线。权重和特征图放在 DDR 里PL 通过 AXI Master 接口读取。PYNQ 框架提供了pynq.Overlay和allocate这样的 API让你在 Python 里直接操作物理内存和寄存器不用写内核驱动。这里有个经验不要把所有权重都塞进 BRAM。YOLOv2 的权重加起来好几兆字节Zynq-7020 的 BRAM 总共才 4.9Mb根本放不下。我的做法是权重放 DDRPL 每次计算时按需读取用 AXI 的突发传输来掩盖延迟。特征图同理中间层的 feature map 也放 DDR只有当前计算需要的那一小块 tile 缓存在片上 BRAM 里。1.3 为什么选 Vivado HLS 而不是手写 RTL这个问题我被问过很多次。手写 Verilog 当然能榨出更高的性能但代价是开发周期长、调试困难、参数化困难。YOLOv2 有 19 层卷积每层的通道数、卷积核大小、步长都不一样如果手写 RTL光是写状态机就能把人逼疯。Vivado HLS 的优势在于你用 C/C 描述算法加 pragma 指导综合工具自动帮你生成流水线、展开循环、分配 BRAM 和 DSP。对于卷积这种规则计算HLS 的综合质量已经相当不错了。而且改参数特别方便——比如想把并行度从 8 改成 16改个宏定义重新综合就行不用重写代码。当然 HLS 也有坑后面会详细说。但总体而言对于这个项目的复杂度HLS 是性价比最高的选择。1.4 Jupyter Notebook 在其中的角色PYNQ 最大的创新就是把 Jupyter Notebook 跑在了板子上。你不需要在 PC 上装一堆交叉编译工具链直接在浏览器里访问板子的 IP就能写 Python 代码、调用 FPGA overlay、看中间结果。这对调试太重要了。传统 FPGA 开发你想看个中间特征图的值得先存到 DDR再通过串口或者以太网传出来麻烦得要命。而在 Jupyter 里overlay 的输入输出都是 numpy array你可以直接plt.imshow()看特征图或者打印数值分布。调试效率至少提升三倍。而且 Notebook 天然适合做实验记录。每一层的输出、每一组参数的效果都可以留在 cell 里方便对比。我整个项目做下来Notebook 里积累了上百个 cell后来整理成了一套完整的推理流程。2. 核心细节解析与实操要点2.1 YOLOv2 网络结构的硬件友好化改造原版 YOLOv2 有一些对硬件不太友好的设计直接搬上去会很难受。我在动手之前先做了几处改造第一BN 层融合进卷积。训练时 BN 是独立的一层但推理时 BN 就是个线性变换完全可以把它和前面的卷积权重合并。具体做法是对于卷积输出y W*x bBN 做的是y gamma * (y - mean) / sqrt(var eps) beta把这两个式子合并得到新的权重W W * gamma / sqrt(var eps)新的偏置b (b - mean) * gamma / sqrt(var eps) beta。这样推理时就只剩卷积加激活省掉了一整层的计算和访存。第二LeakyReLU 用硬件实现。LeakyReLU 是x 0 ? x : 0.1*x这个在硬件里很好做——判断符号位如果是负数就乘以 0.1。0.1 可以近似成1/8 1/16 1/128这种移位相加避免用乘法器。第三池化层用最大池化。YOLOv2 用的是 2×2 步长 2 的最大池化这个在硬件里就是比较四个数取最大非常简单。但要注意池化后的数据要重新排列因为硬件流水线是按行输出的池化需要跨行比较得加个行缓冲。第四去掉最后的 region 层。原版 YOLOv2 最后有个 region 层做解码这个逻辑太复杂不适合硬件。我把它挪到 PS 端用 Python 做PL 只输出原始的特征图。改造后的网络结构大概是19 个卷积层BN 已融合5 个最大池化层最后输出 13×13×425 的特征图对应 13×13 个网格每个网格 5 个 anchor每个 anchor 25 个值4 个坐标 1 个置信度 20 个类别概率。2.2 Vivado HLS 卷积 IP 的设计要点卷积 IP 是整个项目的核心我花了最多时间在这上面。设计目标是在 150MHz 时钟下单层卷积的吞吐率尽量高同时资源占用不超过 Zynq-7020 的预算。并行度设计。卷积的并行有三个维度可以展开输出通道并行、输出像素并行、输入通道并行。我最终选的是输出通道 8 并行、输入通道 8 并行、输出像素 1 个。为什么这么选因为 Zynq-7020 有 220 个 DSP每个 DSP 做一次乘加。如果输出通道 8 并行、输入通道 8 并行那就是 64 个乘法器同时工作占 64 个 DSP留出余量给其他逻辑。输出像素如果也并行DSP 就不够用了。数据复用。卷积最大的优化空间在数据复用。一个 3×3 的卷积核在滑动过程中相邻输出像素会共享大量输入数据。我在 HLS 里用了 line buffer 来缓存输入行这样每个输入像素只需要从 DDR 读一次就能被多个输出像素复用。权重则缓存在 BRAM 里因为权重在不同输出像素间是完全复用的。流水线设计。HLS 的#pragma HLS PIPELINE是关键。我把内层的输入通道循环做了 pipelineIIInitiation Interval设成 1意味着每个时钟周期都能启动一次乘加。但要注意pipeline 的深度不能太深否则 BRAM 的读写冲突会导致 II 达不到 1。我实测下来输入通道循环 pipeline 后配合 line bufferII 能稳定在 1。定点数量化。浮点运算在 FPGA 上太奢侈了。我把所有数据都量化成 16 位定点8 位整数 8 位小数。为什么是 16 位而不是 8 位因为 8 位定点在 YOLOv2 上精度损失太大mAP 会掉十几个点。16 位定点实测下来mAP 只掉 1-2 个点完全可以接受。量化的时候要注意权重的动态范围比激活值大所以权重用 8 位整数 8 位小数激活值用 4 位整数 12 位小数这样能更好地利用位宽。2.3 PYNQ overlay 的封装与 Python 接口设计HLS 综合出来的 IP 不能直接给 Python 用需要包装成一个 PYNQ overlay。这个过程在 Vivado 里完成把 HLS IP 加到 Block Design 里连上 Zynq PS 的 AXI 接口分配地址然后生成 bitstream 和 hwh 文件。地址分配要注意。每个 IP 的寄存器空间要分配独立的地址段避免冲突。我一般给每个卷积 IP 分配 64KB 的地址空间足够放控制寄存器和少量缓存。权重和特征图的 DDR 地址则通过 AXI Master 接口访问需要在 Python 里用pynq.allocate分配连续物理内存。Python 接口设计。PYNQ 的 overlay 加载后每个 IP 会暴露成 Python 对象你可以直接读写它的寄存器。我封装了一个ConvLayer类把寄存器操作、DMA 传输、同步等待都包进去上层只需要调用conv_layer.forward(input_array)就行。这样 Notebook 里的代码就很干净。DMA 的使用。大数据传输一定要用 DMA不要用 MMIO 逐字读写。PYNQ 提供了pynq.lib.dma.DMA类可以配置传输方向和长度。我实测下来DMA 传输 1MB 数据大概几十微秒而 MMIO 逐字读写要几毫秒差了两个数量级。2.4 Jupyter Notebook 环境配置与常见坑PYNQ-Z2 出厂自带的 PYNQ 镜像里就有 Jupyter Notebook但版本可能比较老。我建议刷最新的 PYNQ 2.7 或 3.0 镜像Python 版本和库都更新一些。Jupyter Notebook 打不开怎么办这是最常见的问题。首先确认板子的 IP 地址PYNQ 默认是 192.168.2.99如果你的 PC 不在同一网段肯定打不开。把 PC 的网卡设成 192.168.2.1子网掩码 255.255.255.0然后用网线直连板子。如果还是打不开检查板子的 ETH 灯是否亮或者用串口连上去看启动日志。Jupyter Notebook 单元格执行代码没有任何反应这个通常是内核挂了。PYNQ 的 Jupyter 内核如果加载了有问题的 overlay可能会崩溃。解决办法是重启内核菜单里 Kernel - Restart或者重启板子。如果频繁出现检查你的 overlay 是不是资源超了或者 DMA 传输越界了。运行 Jupyter Notebook 出现 ImportError: DLL load failed while importing rpds这个错误一般出现在 Windows 上装 Jupyter 的时候是rpds-py这个包的 C 扩展没编译好。解决办法是装预编译的 wheelpip install --only-binary :all: rpds-py。如果还不行装个 Visual C Redistributable 试试。Jupyter Notebook 代码自动补齐怎么配默认的 Jupyter 没有自动补齐可以装jupyter-contrib-nbextensions然后启用Hinterland扩展。或者用jupyterlab它自带 LSP 支持补齐体验更好。Jupyter Notebook 怎么生成 markdown 目录语法在 markdown cell 里用[TOC]就行Jupyter 会自动根据标题生成目录。但要注意这个只在 notebook 渲染时有效导出成 HTML 后可能失效。3. 实操过程与核心环节实现3.1 开发环境搭建与工程创建先说环境。我用的工具链是 Vivado 2019.1 Vivado HLS 2019.1 PYNQ 2.7 镜像。为什么用 2019.1 而不是更新的版本因为 PYNQ 2.7 的 overlay 和 2019.1 的 bitstream 兼容性最好用 2020 以后的版本可能会遇到 hwh 文件格式不匹配的问题。第一步创建 Vivado HLS 工程。打开 Vivado HLS新建工程选 Zynq-7020 对应的器件xc7z020clg400-1。时钟周期设 6.67ns对应 150MHz。这个频率是权衡后的结果——再高的话时序很难收敛再低的话性能不够。第二步写卷积的 C 代码。核心函数签名是这样的void conv_layer( data_t* input, // 输入特征图 data_t* weight, // 权重 data_t* bias, // 偏置 data_t* output, // 输出特征图 int in_ch, // 输入通道数 int out_ch, // 输出通道数 int in_h, // 输入高度 int in_w, // 输入宽度 int kernel_size, // 卷积核大小 int stride // 步长 );data_t是 16 位定点类型用ap_fixed16,8定义。第三步加 pragma。关键的 pragma 有这几个#pragma HLS INTERFACE m_axi portinput offsetslave bundlegmem0 #pragma HLS INTERFACE m_axi portweight offsetslave bundlegmem1 #pragma HLS INTERFACE m_axi portoutput offsetslave bundlegmem2 #pragma HLS INTERFACE s_axilite portreturn bundlecontrol这样 input、weight、output 分别走三个 AXI Master 接口控制寄存器走 AXI Lite。为什么要分三个接口因为卷积计算时读输入、读权重、写输出是同时进行的如果共用一个 AXI 接口带宽会成为瓶颈。第四步综合并导出 IP。综合完成后Export RTL选 Vivado IP 格式。导出的 IP 会包含 Verilog 源码、约束文件和一个 xci 文件。3.2 Block Design 搭建与 bitstream 生成HLS 导出的 IP 要放到 Vivado 的 Block Design 里才能用。第一步创建 Vivado 工程。器件同样选xc7z020clg400-1。添加 Zynq Processing System IP配置 DDR 控制器、时钟、UART 等。这里要注意PYNQ 镜像对 PS 的配置有特定要求比如 UART 波特率是 115200SD 卡启动等。如果你直接用 PYNQ 的 base overlay 改可以省掉很多配置工作。第二步添加卷积 IP。把 HLS 导出的 IP 加到 Block Design 里然后连 AXI 接口。每个卷积 IP 的 AXI Lite 接口连到 PS 的 M_AXI_GP0AXI Master 接口连到 PS 的 S_AXI_HP0。如果有多个卷积 IP可以用 AXI Interconnect 来共享接口。第三步分配地址。在 Address Editor 里给每个 IP 分配地址段。我一般从 0x43C00000 开始每个 IP 占 64KB。地址分配好后Python 里就能通过这个地址访问寄存器。第四步生成 bitstream。综合、实现、生成 bitstream整个过程大概 20-30 分钟。生成后会得到.bit文件和.hwh文件这两个文件要一起放到 PYNQ 板子上。3.3 PYNQ overlay 加载与 Python 驱动编写板子启动后通过 Jupyter Notebook 上传.bit和.hwh文件然后加载 overlayfrom pynq import Overlay overlay Overlay(/home/xilinx/yolov2.bit)加载后overlay 里的 IP 会暴露成属性比如overlay.conv_layer_1。你可以直接读写它的寄存器conv1 overlay.conv_layer_1 conv1.register_map.in_ch 3 conv1.register_map.out_ch 32 conv1.register_map.in_h 416 conv1.register_map.in_w 416内存分配。输入、权重、输出都要分配连续物理内存from pynq import allocate import numpy as np input_buf allocate(shape(3, 416, 416), dtypenp.float16) weight_buf allocate(shape(32, 3, 3, 3), dtypenp.float16) output_buf allocate(shape(32, 416, 416), dtypenp.float16)allocate返回的是ContiguousArray它的physical_address属性就是物理地址可以直接写到 IP 的寄存器里。启动计算。把物理地址写到 IP 的寄存器然后启动conv1.register_map.input_addr input_buf.physical_address conv1.register_map.weight_addr weight_buf.physical_address conv1.register_map.output_addr output_buf.physical_address conv1.register_map.start 1 while conv1.register_map.done 0: pass这个轮询等待的方式最简单但效率不高。更好的做法是用中断但 PYNQ 的中断配置稍微麻烦一点后面再说。3.4 完整推理流程的 Notebook 实现整个推理流程在 Notebook 里分成几个 cellCell 1加载 overlay 和权重。权重是从 Darknet 的 weights 文件里读出来的需要先做 BN 融合和量化。我写了个 Python 脚本把浮点权重转成 16 位定点然后存成 numpy array。Cell 2图像预处理。读入一张图缩放到 416×416归一化到 [0,1]然后转成 16 位定点。这里要注意YOLOv2 的输入是 RGB 还是 BGRDarknet 用的是 BGR但 OpenCV 读进来是 BGR所以不用转。如果你用 PIL 读是 RGB要转一下。Cell 3逐层推理。用一个循环遍历所有层每层调用对应的卷积 IP。这里要注意层间的数据依赖——有些层需要上一层的输出作为输入所以要等上一层算完才能启动下一层。我用了双缓冲来 overlap 计算和传输但实现起来比较复杂后面细说。Cell 4后处理。PL 输出的是 13×13×425 的原始特征图需要解码成边界框。解码逻辑是每个网格预测 5 个 anchor每个 anchor 有 4 个坐标偏移、1 个置信度、20 个类别概率。坐标要经过 sigmoid 和 anchor 解码然后做 NMS。Cell 5可视化。用 matplotlib 把检测框画到原图上。3.5 性能实测与瓶颈分析实测下来单层卷积的耗时大概是这样的3×3 卷积输入 416×416×3输出 416×416×32耗时约 8ms。整个 YOLOv2 跑下来大概 200ms 一帧也就是 5 FPS。这个性能不算高但比纯 ARM 软件推理的 2-3 秒已经快了十倍以上。瓶颈在哪我分析下来主要是三个第一DDR 带宽。Zynq-7020 的 DDR 带宽理论上是 4.2GB/s但实际能用到 2GB/s 就不错了。卷积计算时读输入、读权重、写输出都要走 DDR带宽很容易打满。解决办法是增加片上缓存减少 DDR 访问。我后来把部分权重缓存在 BRAM 里性能提升了大概 15%。第二AXI 接口的延迟。每次 DMA 传输都有启动延迟如果传输的数据量小延迟占比就很高。解决办法是合并传输把多个小传输合并成一个大传输。第三流水线气泡。层与层之间需要同步同步的时候流水线是空的。解决办法是用双缓冲让下一层的输入传输和当前层的计算 overlap。这个我试过能提升 20% 左右的性能但代码复杂度增加不少。4. 常见问题与排查技巧实录4.1 HLS 综合报错与时序不收敛问题一综合时报 Cannot find design unit 或者 Unknown type。这个通常是头文件没包含对或者数据类型定义有问题。检查ap_fixed.h有没有 includedata_t的定义是不是在函数之前。问题二时序不收敛WNS 是负的。这是 HLS 最常见的问题。原因可能是组合逻辑太长或者 pipeline 太深。解决办法降低时钟频率比如从 150MHz 降到 100MHz或者把大循环拆成小循环减少单周期的逻辑深度。我一开始用 200MHz时序怎么都收敛不了降到 150MHz 就稳了。问题三BRAM 不够用。Zynq-7020 的 BRAM 只有 4.9Mb如果 line buffer 开太大BRAM 会爆。解决办法减小 line buffer 的深度或者用 LUTRAM 代替 BRAM。LUTRAM 用的是逻辑资源不占 BRAM但容量小、功耗高。问题四DSP 不够用。如果并行度开太高DSP 会不够。Zynq-7020 有 220 个 DSP我的设计用了 64 个还有余量。如果你开到 16 并行就是 256 个 DSP超了。解决办法降低并行度或者用 LUT 实现乘法但性能会下降。4.2 PYNQ overlay 加载失败与 DMA 传输异常问题一overlay 加载时报 Invalid bitstream。这个通常是 bitstream 和 hwh 文件不匹配或者 PYNQ 版本和 Vivado 版本不兼容。检查两个文件的生成时间是不是一致PYNQ 镜像的版本是不是和 Vivado 版本匹配。问题二DMA 传输卡死。这个最让人头疼。原因可能是传输长度超过了 buffer 大小或者物理地址不对或者 DMA 没配置对。排查方法先用小数据量测试比如 1KB确认 DMA 能正常工作再逐步加大数据量。另外allocate分配的内存一定要是连续的如果内存碎片化严重可能会分配失败。问题三IP 寄存器读写没反应。检查地址分配对不对AXI Lite 接口有没有连上。可以在 Vivado 的 Address Editor 里看地址映射然后在 Python 里用overlay.ip_dict看 IP 的地址。问题四计算结果全是 0 或者全是随机值。这个通常是数据没传进去或者量化参数不对。检查输入 buffer 的 physical_address 有没有正确写到寄存器量化的时候有没有溢出。我遇到过权重全变成 0 的情况后来发现是量化时把小数部分全截断了改成四舍五入就好了。4.3 Jupyter Notebook 使用中的典型故障问题一单元格执行代码没有任何反应。前面说过通常是内核挂了。重启内核或者重启板子。如果频繁出现检查 overlay 是不是资源超了。问题二Jupyter Notebook 无法运行报 Kernel died。这个可能是内存不够。PYNQ-Z2 只有 512MB DDR如果分配了太大的 buffer内存会爆。解决办法减小 buffer 大小或者及时释放不用的 buffer用del加gc.collect()。问题三Jupyter Notebook 打不开浏览器显示连接超时。检查网络配置确认 PC 和板子在同一网段。如果用的是 USB 转以太网检查驱动有没有装好。问题四Jupyter Notebook 下载文件很慢。PYNQ 的 Jupyter 是通过以太网传输的如果板子的网络配置不好速度会很慢。可以试试用 SCP 或者 SFTP 传文件比 Jupyter 的上传下载快。4.4 精度损失与量化调优问题一量化后 mAP 掉太多。如果掉超过 5 个点说明量化参数没调好。检查权重的动态范围如果某些层的权重特别大或者特别小可能需要单独调量化比例。我一般会统计每层权重的最大值和最小值然后按层设置量化参数。问题二某些层的输出全是 0。这个通常是激活值量化时下溢了。16 位定点如果小数部分只有 8 位最小值就是 1/256小于这个的数会变成 0。解决办法增加小数部分的位宽或者对激活值做动态缩放。问题三检测框位置偏移。这个可能是坐标解码的问题。YOLOv2 的坐标解码是x (sigmoid(tx) cx) * anchor_w其中cx是网格的左上角坐标。检查一下cx和anchor_w有没有算对。4.5 常见问题速查表问题现象可能原因排查方法解决办法HLS 综合报错头文件缺失或类型错误检查 include 和类型定义补全头文件修正类型时序不收敛组合逻辑太长或频率太高看时序报告找 WNS 最差的路径降频或拆分循环BRAM 不够line buffer 太大看资源报告减小 buffer 或改用 LUTRAMoverlay 加载失败bitstream 和 hwh 不匹配检查文件生成时间重新生成 bitstreamDMA 卡死传输长度或地址错误用小数据量测试修正长度和地址计算结果全 0数据没传进去或量化溢出检查寄存器和量化参数修正地址和量化内核挂了overlay 资源超限看串口日志减小 overlay 规模mAP 掉太多量化参数不对统计权重动态范围按层调量化参数4.6 独家避坑经验经验一先跑通再优化。我一开始就想做双缓冲、多 IP 并行结果调试了两周都没跑通。后来退回到最简单的单 IP 轮询方式一天就跑通了。跑通之后再逐步加优化每次只改一个地方这样出问题容易定位。经验二用仿真验证 HLS 代码。HLS 的 C 仿真很快几秒钟就能跑完。我每次改完代码先用 C 仿真验证功能对不对再综合。这样能省掉大量综合等待时间。经验三权重和特征图分开存。权重是只读的特征图是读写的。如果混在一起BRAM 的读写冲突会很严重。我把权重放 BRAM特征图放 DDR性能提升明显。经验四注意 AXI 的突发长度。AXI 的突发传输最长是 256 拍如果一次传输超过 256 拍会被拆成多次。我一般把传输长度设成 256 的整数倍这样效率最高。经验五Jupyter 里及时释放内存。PYNQ-Z2 的内存很小如果分配了 buffer 不释放很快就会 OOM。我习惯在每个 cell 结束时用del释放不用的 buffer然后gc.collect()。经验六用%time测性能。Jupyter 的%time魔法命令很方便可以快速测每个 cell 的耗时。我一般会在关键 cell 里加%time看看瓶颈在哪。经验七保存中间结果。调试的时候把每层的输出存成 npy 文件方便对比。如果某一层的结果不对可以单独拿出来分析。经验八注意定点数的溢出。16 位定点整数部分 8 位最大值是 127。如果卷积累加的结果超过 127就会溢出。我一般会在累加时用 32 位中间变量最后再截断到 16 位。这个项目做下来最大的体会是FPGA 加速不是简单的“把软件搬到硬件”而是需要重新设计数据流和计算模式。软件里可以随便用的动态内存分配、递归、分支在硬件里都是奢侈品。你得时刻想着数据怎么流动、怎么复用、怎么并行。但一旦跑通那种性能提升的成就感是纯软件开发给不了的。后续我还想试试把 YOLOv3 或者更小的 YOLOv4-tiny 搬上去看看能不能在保持精度的同时把帧率提到 15 FPS 以上。