YOLOv5x 在 Horizon J6 上的端到端部署实践(下):板端推理与精度评估
上篇完成了模型从 PyTorch 到 HBM 的转换。本篇继续介绍板端 C++ 推理部署,包括 NV12 输入构造、UCP/DNN API 调用、YOLOv5 输出解析、交叉编译、板端运行以及 mAP 精度评估。
1. 板端推理整体流程
板端 C++ 推理可以拆成以下几步:
rust
JPG 图片
-> cv::imread 读取 BGR
-> Letterbox Resize 到 640x640,padding=114
-> BGR 转 YUV_I420,再转 NV12
-> 拆成 Y 平面和 UV 平面
-> 填入 hbDNNTensor
-> 调用 hbDNNInferV2 执行 BPU 推理
-> 按 tensor stride 解析输出
-> YOLOv5 decode + NMS
-> 映射回原图坐标
-> 画框并保存结果
部署时最容易出问题的地方主要有两个:
- 输入必须是模型期望的 NV12 双平面格式。
- 输出不能按紧密内存布局读取,必须按 stride 访问。
2. NV12 输入格式
HBM 的输入通常会被拆成两个 tensor:
| Tensor | Shape | 数据类型 | 说明 |
|---|---|---|---|
| images_y | (1, 640, 640, 1) | U8 | Y 亮度平面 |
| images_uv | (1, 320, 320, 2) | U8 | UV 交错色度平面 |
从 OpenCV 读取的 BGR 图片需要先做 letterbox,再转换为 NV12:
rust
BGR
-> Letterbox Resize
-> cv::COLOR_BGR2YUV_I420
-> 拆分 Y/U/V
-> U/V 交错
-> NV12
3. UCP/DNN API 调用流程
板端推理主流程可以简化为:
scss
// 1. 加载模型
hbDNNPackedHandle_t packed_handle;
hbDNNInitializeFromFiles(&packed_handle, &model_path, 1);
hbDNNGetModelHandle(&dnn_handle, packed_handle, model_name_list[0]);
// 2. 准备输入输出 tensor
hbDNNGetInputTensorProperties(&input.properties, dnn_handle, i);
hbUCPMallocCached(&input.sysMem, input_mem_size, 0);
hbDNNGetOutputTensorProperties(&output.properties, dnn_handle, i);
hbUCPMallocCached(&output.sysMem, output_mem_size, 0);
// 3. 填充输入并清 cache
// letterbox + BGR->NV12 + copy to Y/UV tensor
hbUCPMemFlush(&input.sysMem, HB_SYS_MEM_CACHE_CLEAN);
// 4. 提交推理任务
hbDNNInferV2(&task_handle, output, input, dnn_handle);
hbUCPSubmitTask(task_handle, &sched_param);
hbUCPWaitTaskDone(task_handle, 0);
// 5. 读取输出前 invalid cache
hbUCPMemFlush(&output.sysMem, HB_SYS_MEM_CACHE_INVALIDATE);
// 6. 释放资源
hbUCPReleaseTask(task_handle);
hbUCPFree(&input.sysMem);
hbUCPFree(&output.sysMem);
hbDNNRelease(packed_handle);
实际工程中还需要处理错误码、动态 stride、内存大小计算和多输入多输出遍历。
4. 输出解析:必须按 stride 访问
YOLOv5 的输出 tensor 中存在 padding,不能用普通的连续数组方式读取。例如:
ini
shape = (1, 3, 80, 80, 85)
stride = (7372800, 2457600, 30720, 384, 4)
正确访问方式是使用 byte stride 计算偏移:
ini
const uint8_t *base_ptr = static_cast<const uint8_t *>(output.sysMem.virAddr);
int64_t offset = anchor * stride[1]
+ row * stride[2]
+ col * stride[3]
+ k * stride[4];
float value = *reinterpret_cast<const float *>(base_ptr + offset);
不要这样做:
csharp
// 错误:把输出当作紧密布局,会读到 padding
int base = ((anchor * grid_h + row) * grid_w + col) * 85;
这是板端 YOLO 后处理最常见的坑之一。
5. YOLOv5 解码与 NMS
YOLOv5 每个检测头对应一个 stride 和一组 anchors:
makefile
P3/8: (10,13), (16,30), (33,23)
P4/16: (30,61), (62,45), (59,119)
P5/32: (116,90), (156,198), (373,326)
解码公式:
ini
cx = (sigmoid(tx) * 2 - 0.5 + col) * stride
cy = (sigmoid(ty) * 2 - 0.5 + row) * stride
bw = (sigmoid(tw) * 2) ** 2 * anchor_w
bh = (sigmoid(th) * 2) ** 2 * anchor_h
score = sigmoid(obj) * sigmoid(cls)
后处理一般包含:
- 遍历三个检测头。
- 对 obj 和 class 做 sigmoid。
- 按置信度阈值过滤候选框。
- 将 letterbox 坐标映射回原图。
- 执行 NMS。
- 绘制检测框和类别标签。
6. 交叉编译
使用 SDK 自带的 aarch64 交叉编译器:
ini
LINARO_GCC_ROOT="/arm-gnu-toolchain-12.2.rel1-x86_64-aarch64-none-linux-gnu"
export CC="${LINARO_GCC_ROOT}/bin/aarch64-none-linux-gnu-gcc"
export CXX="${LINARO_GCC_ROOT}/bin/aarch64-none-linux-gnu-g++"
cmake .
make -j$(nproc)
CMake 中需要链接 DNN、UCP、OpenCV 等运行依赖:
scss
target_link_libraries(yolov5x_infer
dnn hbucp gflags hlog fmt opencv_world
bpu hbmem hbipcfhal alog jsoncpp cjson vdsp
pthread rt dl)
如果共享库中的部分符号由板端系统运行时提供,可以加入:
bash
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Wl,-unresolved-symbols=ignore-all")
7. 部署到板端运行
将可执行文件、HBM 模型和测试图片拷贝到板端:
ruby
scp build/yolov5x_infer root@<board_ip>:/map/zhonghua.xue/yolov5x/
scp model_output/yolov5x_640x640_nv12.hbm root@<board_ip>:/map/zhonghua.xue/yolov5x/
scp test.jpg root@<board_ip>:/map/zhonghua.xue/yolov5x/
板端执行:
css
cd /map/zhonghua.xue/yolov5x/
./yolov5x_infer \
--model_file yolov5x_640x640_nv12.hbm \
--image_file test.jpg \
--output result.jpg \
--conf_threshold 0.25 \
--nms_threshold 0.45
如果结果框明显异常,优先检查三件事:
- 输入 NV12 是否正确。
- 是否重复做了 /255。
- 输出是否按 tensor stride 读取。
8. mAP 精度评估
部署完成后,可以对比浮点模型和量化模型在 COCO val2017 上的 mAP:
scss
# 浮点 ONNX 模型
python3 stage5_evaluate.py origin 20
python3 stage5_evaluate.py origin
# 量化 BC 模型,PC 端可用 ONEDNN 后端
python3 stage5_evaluate.py quanti 20 --backend ONEDNN
python3 stage5_evaluate.py quanti --backend ONEDNN
# 两者对比
python3 stage5_evaluate.py both 100 --backend ONEDNN
示例结果:
| Metric | Float ONNX | Quantized BC | Diff |
|---|---|---|---|
| mAP | 0.4913 | 0.4826 | -0.0087 |
| mAP_50 | 0.6424 | 0.6509 | +0.0085 |
| mAP_75 | 0.5241 | 0.5054 | -0.0187 |
| mAP_small | 0.3575 | 0.3196 | -0.0379 |
| mAP_med | 0.5280 | 0.5066 | -0.0214 |
| mAP_large | 0.6710 | 0.6771 | +0.0061 |
量化后 mAP 有轻微下降属于正常现象。若下降过大,优先排查校准数据覆盖度、预处理一致性、输入归一化和后处理 decode 是否一致。
9. 关键点速查
| 环节 | 要点 |
|---|---|
| 输入格式 | 板端 HBM 输入为 NV12 双平面 |
| 归一化 | 由编译配置中的 scale_value 完成 |
| 校准数据 | 使用 RGB、NCHW、float32、0,1 |
| 预处理 | Stage 2、Stage 4、Stage 5 的 letterbox 逻辑必须一致 |
| 输出解析 | 必须按 tensor byte stride 访问 |
| 后处理 | YOLOv5 decode、坐标映射、NMS 要和浮点侧保持一致 |
小结
下篇完成了从 HBM 模型到板端 C++ 推理的部署闭环。相比工具链编译,板端实现更容易踩到数据格式和内存布局问题。只要保证 NV12 输入正确、运行时归一化不重复、输出按 stride 解析,YOLOv5x 在 J6 上的端到端部署就能稳定跑通。