地平线征程6M YOLOv8s 部署

0 阅读24分钟

本文记录 YOLOv8s 在地平线 征程6M 上的最终可复现部署流程。正式方案统一采用 Raw6 六路输出、PTQ INT8、NV12 Runtime 输入、CPU 后处理。文中命令需要根据实际 SDK 安装目录、服务器地址和开发板目录调整。

1. 环境搭建

1.1 创建 OpenExplorer 容器

目的:使用固定版本的 OpenExplorer 工具链,隔离模型转换和编译环境。

本文使用的镜像为:

openexplorer/ai_toolchain_ubuntu_22_j6_gpu:v3.8.1

先确认镜像存在:

docker images

准备宿主机目录:

mkdir -p ~/projects
mkdir -p ~/docker-data/xy_j6_381

创建容器:

docker run -it \
  --name xy_j6_381 \
  --gpus all \
  --shm-size=16g \
  -v ~/projects:/workspace/projects \
  -v ~/docker-data/xy_j6_381:/workspace/data \
  openexplorer/ai_toolchain_ubuntu_22_j6_gpu:v3.8.1

参数说明:

参数作用
--name xy_j6_381指定容器名称
--gpus all允许容器使用宿主机 GPU
--shm-size=16g增大共享内存
-v ~/projects:/workspace/projects挂载项目目录
-v ~/docker-data/xy_j6_381:/workspace/data挂载持久化数据目录

1.2 验证工具链和挂载目录

进入容器后检查基础环境:

pwd
python --version
conda --version
nvidia-smi
ls /open_explorer

检查挂载目录:

ls /workspace
ls /workspace/projects
ls /workspace/data

建议把本次模型转换文件集中放到一个目录中:

mkdir -p /workspace/projects/j6m/yolov8s_raw6
cd /workspace/projects/j6m/yolov8s_raw6

1.3 常用容器命令

退出容器:

exit

启动并重新进入容器:

docker start xy_j6_381
docker exec -it xy_j6_381 /bin/bash

检查容器状态:

docker ps -a | grep xy_j6_381

2. 最终部署方案

2.1 整体流程

正式部署主线如下:

yolov8s.pt
→ 修改 Detect Head
→ 导出 yolov8s_raw6.onnx
→ 输出 P3/P4/P5 三个尺度的 box 和 cls,共六路输出
→ 准备 RGB、NCHW、float32、[0,1] 校准数据
→ 使用 PTQ INT8 编译
→ 生成 yolov8s_raw6_640x640_nv12.hbm
→ 评测 ONNX、优化、校准、量化及 HBM 各阶段精度
→ 确认 HBM 精度满足要求
→ 部署 HBM 到 J6M 开发板
→ 板端输入 NV12
→ 获取六路原始输出
→ CPU 完成 DFL、Anchor 解码、Sigmoid、NMS 和坐标还原
→ 测试延迟、FPS 和资源占用

2.2 为什么导出 Raw6

Ultralytics 默认导出的检测模型通常把 DFL、坐标解码和输出拼接放在图中,最终形成单路检测输出。该形式便于通用推理,但解码算子和大张量拼接可能增加量化误差或工具链适配难度。

Raw6 方案只保留三个尺度的回归分支和分类分支,把以下操作移到 CPU:

DFL Softmax
→ 距离加权
→ Anchor Point 解码
→ 分类 Sigmoid
→ P3/P4/P5 合并
→ 置信度筛选
→ 按类别 NMS
→ LetterBox 坐标还原

这样可以逐层对齐 PyTorch、ONNX、量化模型和板端结果,也便于定位精度问题。

2.3 最终模型输入输出约定

模型训练侧和校准侧输入:

颜色格式:RGB
布局:NCHW
数据类型:float32
单张 Shape:[3,640,640]
模型输入 Shape:[1,3,640,640]
数值范围:[0,1]

板端 Runtime 输入:

颜色格式:NV12
数据类型:uint8
分辨率:640×640

Raw6 输出约定:

p3_box:[1,64,80,80]
p3_cls:[1,80,80,80]

p4_box:[1,64,40,40]
p4_cls:[1,80,40,40]

p5_box:[1,64,20,20]
p5_cls:[1,80,20,20]

其中,64 = 4 × reg_max,YOLOv8 默认 reg_max=16;80 是 COCO 类别数。若使用自定义类别数,分类分支的通道数应以实际模型为准。

输出名称、顺序、布局和数据类型必须以 ONNX 检查结果、编译报告及 hrt_model_exec model_info 的实际输出为准,不要仅依赖代码中的固定下标。


3. 导出 Raw6 ONNX

3.1 安装依赖

目的:准备与 yolov8s.pt 兼容的 Ultralytics、PyTorch、ONNX 和 ONNX Runtime 环境。

python -m pip install ultralytics onnx onnxruntime onnxsim

记录实际版本,便于复现:

python -c "import torch, ultralytics, onnx, onnxruntime; print(torch.__version__, ultralytics.__version__, onnx.__version__, onnxruntime.__version__)"

不同 Ultralytics 版本的 Detect 实现可能变化。修改前应先确认当前版本源码,不要直接套用其他版本的补丁。

3.2 修改 Detect Head 并导出

目的:让检测头在导出模式下直接返回 P3/P4/P5 的回归和分类特征,不在 ONNX 图中执行 DFL 和坐标解码。

核心逻辑示意:

def forward_raw6(self, x):
    outputs = []
    for i in range(self.nl):
        box = self.cv2[i](x[i])
        cls = self.cv3[i](x[i])
        outputs.extend([box, cls])
    return tuple(outputs)

需要根据当前 Ultralytics 版本把该逻辑接入 Detect.forward 或使用导出包装模块。正式导出时固定输入尺寸和输出名称:

import torch
from ultralytics import YOLO

model = YOLO("yolov8s.pt").model.eval()
dummy = torch.zeros(1, 3, 640, 640)

torch.onnx.export(
    model,
    dummy,
    "yolov8s_raw6.onnx",
    input_names=["images"],
    output_names=[
        "p3_box", "p3_cls",
        "p4_box", "p4_cls",
        "p5_box", "p5_cls",
    ],
    opset_version=11,
    do_constant_folding=True,
)

上述代码是调用框架示意。导出前必须确认 model(dummy) 已返回六个 Tensor;若仍返回单路结果,说明 Detect Head 修改尚未生效。

3.3 检查 ONNX 输入输出

先执行 ONNX 结构检查:

python -c "import onnx; m=onnx.load('yolov8s_raw6.onnx'); onnx.checker.check_model(m); print('ONNX check passed')"

再打印输入输出信息:

import onnxruntime as ort

session = ort.InferenceSession(
    "yolov8s_raw6.onnx",
    providers=["CPUExecutionProvider"],
)

for item in session.get_inputs():
    print("input:", item.name, item.shape, item.type)

for item in session.get_outputs():
    print("output:", item.name, item.shape, item.type)

检查重点:

  • 输入名是否为 images。
  • 输入 Shape 是否为 [1,3,640,640]。
  • 输出数量是否为 6。
  • 输出名称和 Shape 是否与第 2.3 节一致。
  • ONNX 输出顺序是否与板端后处理的绑定顺序一致。

3.4 验证浮点 ONNX 精度

目的:先证明 Raw6 导出和 CPU 后处理正确,再进入量化。若 ONNX 已经偏离 PyTorch,后续 PTQ 结果没有参考价值。

对同一验证集使用相同的:

  • LetterBox 参数和填充值。
  • RGB 转换与 /255 归一化。
  • 置信度阈值和 NMS IoU 阈值。
  • DFL、Anchor Point 和 stride 约定。
  • 标签映射与 COCO mAP 评测脚本。

至少记录:

阶段mAP50-95mAP50Recall结论
PyTorch yolov8s.pt待单独评测待单独评测待填写原始 PyTorch 基线
Raw6 yolov8s_raw6.onnx0.5370.696未输出Raw6 导出正确

如果差异明显,优先排查输出顺序、DFL 维度、stride、Anchor Point、LetterBox 和 NMS,而不是继续编译。


4. PTQ 校准与 HBM 编译

4.1 准备校准图片

目的:用具有代表性的样本估计激活分布。校准图片应覆盖实际部署中的目标尺寸、光照、背景和拍摄角度。

建议从训练集或验证集中抽取有代表性的图片;样本数量应结合数据分布和工具链建议确定,不应只追求固定数量。

mkdir -p calibration_images
mkdir -p calibration_data_rgb_f32

4.2 校准预处理流程

校准链路必须与 ONNX 输入约定一致:

OpenCV 读取 BGR 图片
→ LetterBox 到 640×640
→ BGR 转 RGB
→ HWC 转 CHW
→ 转为 float32
→ 除以 255,归一化到 [0,1]
→ 保存单张 [3,640,640] 数据

核心代码:

import cv2
import numpy as np

def preprocess_for_calibration(image_path):
    bgr = cv2.imread(str(image_path))
    if bgr is None:
        raise ValueError(f"无法读取图片:{image_path}")

    # letterbox() 必须与精度评测和板端实现使用相同的缩放、取整及填充规则
    padded_bgr = letterbox(bgr, new_shape=(640, 640), color=(114, 114, 114))
    rgb = cv2.cvtColor(padded_bgr, cv2.COLOR_BGR2RGB)
    chw = rgb.transpose(2, 0, 1)
    return np.ascontiguousarray(chw, dtype=np.float32) / 255.0

保存前抽查:

print(array.shape)
print(array.dtype)
print(float(array.min()), float(array.max()))

预期约定:

Shape:(3,640,640)
dtype:float32
范围:[0,1]

具体文件格式由当前版本 OpenExplorer 配置要求决定。若配置要求二进制文件,应使用连续内存数据写入;若接受 .npy,则保留 Shape 和 dtype 信息。

4.3 生成 YAML 模板

目的:先由当前工具链生成完整模板,再修改关键字段,避免遗漏版本相关配置。

hb_config_generator \
  --full-yaml \
  --model yolov8s_raw6.onnx \
  --march nash-e

J6M 使用的 march 应结合当前 OpenExplorer 版本和芯片资料确认;本文按原环境采用 nash-e。

4.4 编写正式 YAML

正式配置文件统一命名为 yolov8s_raw6_config.yaml,核心配置如下:

model_parameters:
  onnx_model: "./yolov8s_raw6.onnx"
  march: "nash-e"
  working_dir: "./model_output_raw6_int8"
  output_model_file_prefix: "yolov8s_raw6_640x640_nv12"
  output_nodes: ""

input_parameters:
  input_name: "images"
  input_type_rt: "nv12"
  input_type_train: "rgb"
  input_layout_train: "NCHW"
  input_shape: "1x3x640x640"
  mean_value: ""
  scale_value: "0.003921568627451"
  std_value: ""

calibration_parameters:
  cal_data_dir: "./calibration_data_rgb_f32"
  calibration_type: "default"

compiler_parameters:
  optimize_level: "O2"
  compile_mode: "latency"
  core_num: 1
  jobs: 16

关键约定:

校准链路:RGB + NCHW + float32 + [0,1]
板端链路:NV12 + uint8

input_name 必须与 ONNX 实际输入名一致。scale_value 与校准数据是否已经归一化存在工具链版本和配置语义差异时,应依据当前 SDK 文档和编译日志确认,避免重复归一化。

4.5 编译生成 HBM

执行正式 PTQ 编译:

hb_compile -c yolov8s_raw6_config.yaml

目标输出文件为:

model_output_raw6_int8/yolov8s_raw6_640x640_nv12.hbm

编译成功只说明模型能够生成目标产物,不代表量化精度正确。

4.6 查看编译报告

检查 model_output_raw6_int8 中实际生成的日志、HTML、JSON、BC 和 HBM 文件:

find model_output_raw6_int8 -maxdepth 2 -type f | sort

重点检查:

  • 是否存在不支持算子或 CPU fallback。
  • 六路输出是否保留。
  • 输入转换是否为 NV12 Runtime 到 RGB Train 输入。
  • 各节点量化信息和异常饱和情况。
  • 静态性能估计、BPU core 分配和内存占用。
  • 最终 HBM 文件名是否为 yolov8s_raw6_640x640_nv12.hbm。

5. 量化精度评测

5.1 为什么必须先测精度

模型成功编译不等于精度正确。输入颜色、归一化、输出顺序、量化尺度或后处理中的任一处不一致,都可能在程序正常运行的情况下产生错误结果。

必须先确认 HBM 精度,再进行板端延迟和 FPS 测试。

5.2 评测阶段

按照以下顺序逐级评测:

PyTorch
→ Raw6 ONNX
→ optimized BC
→ calibrated BC
→ quantized BC
→ HBM

每一级均使用同一验证集、同一前处理、同一 Raw6 后处理、同一阈值和同一指标实现。若某一级首次出现明显下降,排查范围即可收敛到该转换阶段。

建议同时进行两类对齐:

  1. 数据集级指标对齐:比较 mAP50-95、mAP50、Recall。
  2. 样本级张量对齐:保存相同图片在六路输出上的 min/max、均值、余弦相似度或误差分布,并比较解码后的候选框。

5.3 指标记录表

阶段模型文件输入形式mAP50-95mAP50Recall说明
PyTorchyolov8s.ptRGB FP32待单独评测待单独评测待填写原始 PyTorch 基线
Raw6 ONNXyolov8s_raw6.onnx 或 *_original_float_model.onnxNCHW RGB FP32,[0,1]0.5370.696未输出Raw6 导出正确
Optimized ONNX*_optimized_float_model.onnxNCHW RGB FP32,[0,1]0.5370.696未输出图优化无精度影响
Calibrated ONNX*_calibrated_model.onnxNCHW RGB FP32,量化仿真0.5290.685未输出INT8 校准后仅小幅下降
PTQ ONNX*_ptq_model.onnxNCHW RGB FP32,量化仿真0.5290.685未输出PTQ 转换未引入额外损失
Quantized BC*_quantized_model.bcNHWC NV12 uint80.5370.687待填写需确认六路输出反量化
HBMyolov8s_raw6_640x640_nv12.hbmNHWC NV12 uint8待评测待评测待填写最终板端模型

当前已测阶段的精度变化:

Raw6 ONNX → Calibrated ONNX
mAP50-95:0.537 → 0.529,下降 0.008
mAP50:   0.696 → 0.685,下降 0.011

Calibrated ONNX → PTQ ONNX
mAP50-95:0.529 → 0.529,无额外下降
mAP50:   0.685 → 0.685,无额外下降

目前可以确认图优化没有引入可见精度损失,校准阶段出现小幅下降,PTQ 转换未继续扩大损失。由于 PyTorch、Quantized BC 和 HBM 指标尚未完成,暂时不能得出完整的端到端精度结论。

5.4 精度下降判断

精度是否可接受应以项目验收阈值为准,不应直接套用固定百分比。建议按以下顺序定位:

PyTorch ≠ ONNX
→ 检查 Raw6 导出、DFL、Anchor、stride、Sigmoid、NMS

ONNX ≠ Optimized
→ 检查图优化及算子替换

Optimized ≠ Calibrated
→ 检查校准样本和预处理

Calibrated ≠ Quantized
→ 检查敏感节点、量化参数和饱和分布

Quantized ≠ HBM
→ 检查 NV12 输入、色彩转换、stride、输出类型和板端后处理

只有 HBM 指标达到项目要求后,才进入性能验收。


6. 板端部署与性能测试

6.1 拷贝 HBM 到开发板

先在主机确认文件:

ls -lh model_output_raw6_int8/yolov8s_raw6_640x640_nv12.hbm

示例拷贝命令:

scp model_output_raw6_int8/yolov8s_raw6_640x640_nv12.hbm \
  root@192.168.0.65:/userdata/xy/yolov8s_raw6/

服务器地址和板端目录按实际环境修改。

6.2 查看 HBM 输入输出

板端必须先执行:

cd /userdata/xy/yolov8s_raw6

hrt_model_exec model_info \
  --model_file yolov8s_raw6_640x640_nv12.hbm

本次实测环境:

项目实际值
模型名称yolov8s_raw6_640x640_nv12
RuntimeUCP/DNN 3.12.1,HBRT 4.4.6
BPULib2.2.6
Builder3.5.11
HBDK4.9.7
HMCT2.7.3
Marchnash-e
HBM 加载到 DDR547.77 ms,仅为本次 model_info 记录

NV12 Runtime 在 HBM 中表现为两个输入 Tensor,分别承载 Y 平面和 UV 平面:

输入名称Valid Shape类型量化Stride
0images_y(1,640,640,1)HB_DNN_TENSOR_TYPE_U8NONE(-1,-1,1,1)
1images_uv(1,320,320,2)HB_DNN_TENSOR_TYPE_U8NONE(-1,-1,2,1)

六路输出均为 FP32,未附带量化参数:

输出名称Valid Shape类型Aligned Byte SizeStride
0p3_box(1,64,80,80)HB_DNN_TENSOR_TYPE_F321966080(1966080,30720,384,4)
1p3_cls(1,80,80,80)HB_DNN_TENSOR_TYPE_F322457600(2457600,30720,384,4)
2p4_box(1,64,40,40)HB_DNN_TENSOR_TYPE_F32655360(655360,10240,256,4)
3p4_cls(1,80,40,40)HB_DNN_TENSOR_TYPE_F32819200(819200,10240,256,4)
4p5_box(1,64,20,20)HB_DNN_TENSOR_TYPE_F32163840(163840,2560,128,4)
5p5_cls(1,80,20,20)HB_DNN_TENSOR_TYPE_F32204800(204800,2560,128,4)

实测结果确认:

Runtime 输入:2 个 U8 Tensor,分别为 Y 平面和 UV 平面
模型输出:6 个 FP32 Tensor
输出顺序:p3_box、p3_cls、p4_box、p4_cls、p5_box、p5_cls

输入 stride 显示为动态值。应用程序不能把 -1 当作实际步长,也不能固定假设只有一个输入 Tensor。应按 SDK 的 Tensor 分配结果或明确设置的实际 stride 写入 Y、UV 两个平面。

6.3 测试单线程延迟

具体子命令参数可能随 SDK 版本变化,先查看帮助:

hrt_model_exec --help
hrt_model_exec perf --help

本次使用 1 线程、2000 帧进行较长时间测试:

hrt_model_exec perf \
  --model_file yolov8s_raw6_640x640_nv12.hbm \
  --thread_num 1 \
  --frame_count 2000

记录:

指标结果
工具线程平均延迟1.778251 ms
工具统计平均延迟1.778 ms
最小延迟1.729 ms
最大延迟2.177 ms
帧数2000
程序运行时间3675.454 ms
帧累计延迟3556.503 ms
单线程吞吐544.194 FPS
HBM 加载到 DDR530.64 ms,不计入逐帧平均延迟

本次命令没有通过 --input_file 提供真实 NV12 数据。工具为动态 stride 自动采用:

images_y: (409600,640,1,1)
images_uv:(204800,640,2,1)

因此,1.778 ms 和 544.194 FPS 可用于记录当前 HBM 的单线程工具级 BPU 性能,但不能作为真实图片端到端结果。该数据不包含图片读取、LetterBox、NV12 转换、Raw6 CPU 后处理、NMS 和可视化耗时;若模型中存在对输入范围敏感的算子,还应使用有效的 --input_file 重新测试。

6.4 测试多线程 FPS

本次对 1、2、4、8 线程分别执行 2000 帧测试:

MODEL=yolov8s_raw6_640x640_nv12.hbm

for THREADS in 1 2 4 8; do
  hrt_model_exec perf \
    --model_file "${MODEL}" \
    --thread_num "${THREADS}" \
    --frame_count 2000 \
    2>&1 | tee "perf_thread_${THREADS}.log"
done
线程数Batch测试时长FPS平均延迟
113675.454 ms / 2000 帧544.1941.778 ms
212945.075 ms / 2000 帧679.1572.890 ms
412945.172 ms / 2000 帧679.1365.798 ms
812946.025 ms / 2000 帧678.93311.672 ms

从本次工具测试可以看出:

  • 2 线程相对 1 线程,吞吐从 544.194 FPS 提升到 679.157 FPS,约提升 24.8%。
  • 从 2 线程增加到 4 或 8 线程后,吞吐基本不再增长,稳定在约 679 FPS。
  • 并发继续增加时,单帧平均延迟近似按线程数增长:2.890 → 5.798 → 11.672 ms。
  • 如果目标是该测试条件下的最高吞吐且兼顾延迟,2 线程是更合理的配置;4、8 线程没有带来有效吞吐收益。

上述多线程测试同样未提供 --input_file,结论仅适用于当前 HBM 的工具级调度和 BPU 吞吐,不代表真实业务程序的端到端并发性能。


7. 板端推理程序

7.1 程序整体流程

读取原始图片或视频帧
→ LetterBox 到 640×640
→ BGR 转 NV12
→ 加载 HBM 并查询输入输出属性
→ 按实际 Tensor 属性申请连续内存
→ 写入 NV12 数据并清理输入 Cache
→ 创建、提交并等待 BPU 推理任务
→ 失效输出 Cache
→ 读取六路原始输出
→ DFL + Anchor 解码 + Sigmoid
→ 合并三尺度并执行按类别 NMS
→ 还原 LetterBox 坐标
→ 绘制或输出检测结果
→ 释放任务、Tensor 和模型资源

7.2 NV12 预处理

正式方案的板端输入是 NV12,不使用 RGB Runtime 的 pixel - 128 写入逻辑。

预处理必须统一以下规则:

  • LetterBox 缩放和填充规则与校准、评测一致。
  • 输出尺寸为 640×640;NV12 宽高必须满足相应对齐要求。
  • BGR/RGB 到 YUV 的色彩矩阵和 UV 排列与模型 Runtime 输入一致。
  • 区分 NV12(UV 交错)和 NV21(VU 交错)。
  • 按 Tensor 的 validShape、alignedShape 和 stride 写入,不能默认数据紧密排列。

建议保留的接口:

struct LetterBoxInfo {
    float scale;
    int pad_left;
    int pad_top;
    int input_width;
    int input_height;
    int original_width;
    int original_height;
};

bool LetterBoxToNV12(
    const cv::Mat& bgr,
    int dst_width,
    int dst_height,
    std::vector& nv12,
    LetterBoxInfo& info);

NV12 数据大小通常为:

const size_t nv12_size = width * height * 3 / 2;

实际写入大小和分平面方式必须以 HBM 输入 Tensor 属性及 SDK API 约定为准。

7.3 模型加载与 Tensor 内存

模型加载和属性查询顺序:

hbDNNInitializeFromFiles
→ hbDNNGetModelNameList
→ hbDNNGetModelHandle
→ hbDNNGetInputCount
→ hbDNNGetOutputCount
→ hbDNNGetInputTensorProperties
→ hbDNNGetOutputTensorProperties

不要只为第 0 个输出申请内存。应按实际 output_count 遍历:

std::vector output_tensors(output_count);

for (int i = 0; i < output_count; ++i) {
    hbDNNGetOutputTensorProperties(
        &output_tensors[i].properties,
        dnn_handle,
        i);

    const int bytes = TensorMemSize(output_tensors[i].properties);
    hbUCPMallocCached(&output_tensors[i].sysMem, bytes, 0);
}

TensorMemSize 应根据实际数据类型、对齐 Shape 和 SDK 的 Tensor 属性计算。

7.4 BPU 推理调度

关键调用顺序:

hbUCPMemFlush(&input_tensor.sysMem, HB_SYS_MEM_CACHE_CLEAN);

hbUCPTaskHandle_t task_handle = nullptr;
hbDNNInferV2(
    &task_handle,
    output_tensors.data(),
    input_tensors.data(),
    dnn_handle);

hbUCPSchedParam sched_param;
HB_UCP_INITIALIZE_SCHED_PARAM(&sched_param);
sched_param.backend = HB_UCP_BPU_CORE_ANY;

hbUCPSubmitTask(task_handle, &sched_param);
hbUCPWaitTaskDone(task_handle, 0);

for (auto& tensor : output_tensors) {
    hbUCPMemFlush(
        &tensor.sysMem,
        HB_SYS_MEM_CACHE_INVALIDATE);
}

正式代码需要检查每个 API 的返回值,并在失败路径释放已经申请的资源。

7.5 Raw6 CPU 后处理

统一后处理流程:

六路原始输出
→ 回归分支按 4×16 重排
→ 对每个方向执行 DFL Softmax
→ 用 [0,1,...,15] 加权得到 l/t/r/b 距离
→ 根据当前尺度 Anchor Point 和 stride 解码边界框
→ 分类分支执行 Sigmoid
→ 合并 P3/P4/P5 候选框
→ 置信度筛选
→ 按类别 NMS
→ LetterBox 坐标还原

三个尺度的 stride 通常为:

constexpr int kStrides[3] = {8, 16, 32};
constexpr int kRegMax = 16;

核心接口建议:

struct Detection {
    cv::Rect2f box;
    float score;
    int class_id;
};

std::vector PostprocessRaw6(
    const std::vector& outputs,
    const LetterBoxInfo& letterbox,
    float confidence_threshold,
    float nms_threshold);

输出绑定不应只依赖数组位置。建议同时校验名称、Shape 和通道数,建立 p3_box 到 p5_cls 的明确映射。

坐标还原:

x_original = (x_input - pad_left) / scale;
y_original = (y_input - pad_top) / scale;

还原后需要裁剪到原图边界。

7.6 可视化和资源释放

可视化只保留必要调用:

cv::rectangle(image, box, color, 2);
cv::putText(image, label, origin, cv::FONT_HERSHEY_SIMPLEX, 0.5, color, 1);
cv::imwrite(output_path, image);

资源释放顺序:

hbUCPReleaseTask(task_handle);

for (auto& tensor : input_tensors) {
    hbUCPFree(&tensor.sysMem);
}

for (auto& tensor : output_tensors) {
    hbUCPFree(&tensor.sysMem);
}

hbDNNRelease(packed_dnn_handle);

完整实现建议拆分为:

src/main.cpp
src/yolov8_raw6_postprocess.cpp
include/yolov8_raw6_postprocess.h
CMakeLists.txt
toolchain_aarch64.cmake

7.7 交叉编译与运行

原环境使用的交叉编译器:

/arm-gnu-toolchain-12.2.rel1-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu-g++

建议使用单层构建目录:

cmake -S . -B build_aarch64 \
  -DCMAKE_TOOLCHAIN_FILE=toolchain_aarch64.cmake \
  -DDEPS_ROOT=/workspace/projects/j6m/horizon_j6_open_explorer_v3.8.1-py310_20260326/samples/ucp_tutorial/deps_aarch64

cmake --build build_aarch64 -j4

拷贝程序、模型和测试数据:

scp build_aarch64/run_yolov8s_raw6 \
  root@192.168.0.65:/userdata/xy/yolov8s_raw6/

scp model_output_raw6_int8/yolov8s_raw6_640x640_nv12.hbm \
  root@192.168.0.65:/userdata/xy/yolov8s_raw6/

板端运行示例:

cd /userdata/xy/yolov8s_raw6

./run_yolov8s_raw6 \
  --model_file ./yolov8s_raw6_640x640_nv12.hbm \
  --image_file ./test.jpg \
  --output_file ./result.jpg \
  --conf_thres 0.25 \
  --nms_thres 0.45

动态库目录应根据开发板 BSP 和实际部署目录配置,不要照搬其他项目的 LD_LIBRARY_PATH。


8. 踩坑记录

本节记录早期默认单输出 RGB Runtime 模型的排查过程。当前正式方案已经改为 Raw6 六路输出和 NV12 Runtime,因此本节中的输入和后处理实现不能直接用于最终模型。

8.1 默认 ONNX 包含 DFL 和坐标解码

现象

默认导出的 ONNX 输出为 [1,84,8400],图中包含 DFL、坐标解码、拼接等操作;量化后精度下降明显或难以逐层定位。

排查过程

对比 PyTorch、默认 ONNX 和量化输出,确认差异集中在检测头末端及单路输出。

根因

默认导出形式把对量化较敏感的解码和拼接留在模型图中,同时失去了逐尺度、逐分支对齐能力。

修改方式

修改 Detect Head,导出 P3/P4/P5 的 box 和 cls 六路原始输出;DFL、Anchor 解码和 NMS 移到 CPU。

修改前后结果

修改前:单输出 [1,84,8400]
修改后:六路 Raw6 输出,Shape 见第 2.3 节

对当前方案的影响

当前 Raw6 后处理不得再按 [1,84,8400] 解析。

8.2 单输出 INT8 模型精度下降

现象

早期 HBM 可以正常运行,但类别、置信度、框数量和 ONNX 明显不一致。

排查过程

依次检查 LetterBox、坐标映射、校准数据范围、YAML 归一化和 Runtime Tensor 类型。单图结果显示,问题并非只由 NMS 阈值造成。

根因

早期方案同时混用了默认单输出模型、RGB Runtime 和板端 INT8 Tensor 写入约定,导致输入分布或模型末端量化误差难以区分。

修改方式

最终改用 Raw6 + NV12:用 Raw6 缩小量化排查范围,用 NV12 Runtime 统一板端图像输入。

修改前后结果

旧方案中曾通过修正 RGB INT8 写入改善单图对齐,但这只能证明旧模型的输入编码问题得到缓解,不能代替当前 HBM 的数据集级 mAP 评测。

对当前方案的影响

旧方案结果不得作为 Raw6 + NV12 的精度结论。

8.3 RGB Runtime 的 pixel-128 问题

现象

早期 RGB Runtime HBM 查询结果表现为 NHWC INT8 输入。直接把 uint8 像素强制转换为 int8 后,板端结果与 ONNX 不一致。

排查过程

确认 LetterBox 参数一致后,比较原始写入和 pixel - 128 写入。旧模型修正后,单图类别和置信度更接近 ONNX。

根因

该旧模型使用 RGB INT8 Runtime 输入约定,板端应把 0~255 映射到 -128~127,不能直接发生溢出式转换。

修改方式

旧 RGB Runtime 模型使用:

dst[index] = static_cast(
    static_cast(pixel) - 128);

修改前后结果

原笔记中的单图样例在修改后基本对齐,但没有提供完整数据集指标。

对当前方案的影响

该问题来自早期的默认单输出 RGB Runtime 模型,不适用于当前 Raw6 + NV12 最终方案。

正式 NV12 输入流程中不要加入 pixel - 128。

8.4 Raw6 输出顺序和 Shape 对齐

现象

六路 Tensor 数量正确,但解码结果异常,常见表现为框尺度错误、类别置信度异常或完全无框。

排查过程

分别打印六路输出的名称、Shape、数据类型和数值范围,并与 ONNX Runtime 输出逐路比较。

根因

可能原因包括:

  • 把 cls 分支当作 box 分支。
  • P3/P4/P5 顺序错误。
  • NCHW/NHWC 解释错误。
  • 使用 validShape 访问对齐后的 Tensor 时忽略 stride。
  • 忽略输出量化类型或反量化参数。

修改方式

按名称、Shape 和通道数建立显式映射;以 hrt_model_exec model_info 和 Tensor 属性查询结果为准。

修改前后结果

修改前:按固定数组下标盲目解析
修改后:校验名称 + Shape + 类型后绑定六路输出

对当前方案的影响

Raw6 后处理必须在启动阶段验证全部六路输出,验证失败应立即终止推理。

8.5 校准前处理与 YAML 不一致

现象

编译可以完成,但 calibrated、quantized 或 HBM 阶段精度突然下降。

排查过程

检查校准文件的颜色、Shape、dtype、数值范围,并逐项核对 input_type_train、input_layout_train、mean_value、scale_value 和 std_value。

根因

常见问题包括 BGR/RGB 颠倒、HWC/CHW 颠倒、重复 /255、没有 /255、LetterBox 规则不同或校准样本不具代表性。

修改方式

统一校准输入为:

RGB + CHW + float32 + [0,1]

并用同一预处理函数驱动 ONNX 精度评测和校准数据生成。

修改前后结果

数据集指标必须重新评测,结果填写到第 5.3 节。

对当前方案的影响

校准链路和板端链路的数据格式不同,但它们在模型内部转换后的有效输入分布必须一致。


9. 常用 API 速查

9.1 OpenCV API

API作用
cv::imread读取 BGR 图片
cv::resize等比例缩放
cv::copyMakeBorderLetterBox 填充
cv::cvtColor颜色空间转换
cv::dnn::NMSBoxes候选框 NMS;按类别调用
cv::rectangle绘制检测框
cv::putText绘制类别和置信度
cv::imwrite保存结果图

9.2 Horizon DNN API

API作用
hbDNNInitializeFromFiles加载 HBM 模型包
hbDNNGetModelNameList获取模型名称列表
hbDNNGetModelHandle获取具体模型句柄
hbDNNGetInputCount查询输入数量
hbDNNGetOutputCount查询输出数量
hbDNNGetInputTensorProperties查询输入 Tensor 属性
hbDNNGetOutputTensorProperties查询输出 Tensor 属性
hbDNNInferV2创建推理任务
hbDNNRelease释放模型包

9.3 Horizon UCP API

API作用
hbUCPMallocCached申请 BPU/CPU 可访问的缓存内存
hbUCPMemFlush(...CLEAN)把 CPU 写入同步给 BPU
HB_UCP_INITIALIZE_SCHED_PARAM初始化调度参数
hbUCPSubmitTask提交推理任务
hbUCPWaitTaskDone等待任务完成
hbUCPMemFlush(...INVALIDATE)让 CPU 读取 BPU 更新后的输出
hbUCPReleaseTask释放任务
hbUCPFree释放 Tensor 内存

9.4 API 调用顺序

hbDNNInitializeFromFiles
→ hbDNNGetModelNameList
→ hbDNNGetModelHandle
→ 查询输入输出数量和 Tensor 属性
→ hbUCPMallocCached
→ 写入 NV12 输入
→ hbUCPMemFlush(CLEAN)
→ hbDNNInferV2
→ 初始化调度参数
→ hbUCPSubmitTask
→ hbUCPWaitTaskDone
→ hbUCPMemFlush(INVALIDATE)
→ 读取六路输出并执行 Raw6 后处理
→ hbUCPReleaseTask
→ hbUCPFree
→ hbDNNRelease

职责划分:

DNN API:模型、Tensor 属性、推理任务创建
UCP API:内存、Cache、任务提交与等待
OpenCV API:图像预处理、NMS、绘制和保存

10. 最终总结

本次部署的固定主线是:

yolov8s.pt
→ yolov8s_raw6.onnx
→ RGB NCHW FP32 校准
→ PTQ INT8
→ yolov8s_raw6_640x640_nv12.hbm
→ NV12 板端输入
→ 六路 Raw6 输出
→ CPU 完成 DFL、Anchor 解码、Sigmoid、NMS 和坐标还原

部署验收必须同时满足:

  • Raw6 ONNX 与 PyTorch 精度对齐。
  • optimized、calibrated、quantized 各阶段精度变化可解释。
  • HBM 使用实际 NV12 链路评测且满足项目精度阈值。
  • 板端程序根据实际 Tensor 属性处理 Y、UV 两个输入 Tensor 和六路 FP32 输出。
  • 延迟、FPS、CPU/BPU 占用和内存满足项目要求。
  • 所有评测均记录数据集、阈值、工具版本和运行条件。