这段时间尝试了一下把单目 3D 检测接进 Ultralytics,一开始只是想让 YOLO 在输出 2D 框之外,再给出物体的深度、尺寸和朝向。真正动手以后才发现,单目 3D 最麻烦的并不是多加几路回归,而是图像、标定矩阵、数据增强、三维损失和后处理必须一直保持一致。中间只要有一处几何关系没有对齐,2D 检测可能看着正常,3D 位置却会慢慢跑偏。

现在这套流程已经整理成一个新的 detect3d 任务,使用方式和大家熟悉的 Ultralytics 基本一致:train 训练、val 验证、predict 可视化、export 导出。本文把数据准备、训练命令、KITTI R40、PyTorch 推理和 ONNX Runtime 部署连起来讲一遍,方便第一次接触的同学直接跑通。

项目地址

先用一张图看一下整条链路。和普通检测相比,Detect3D 多了一条贯穿数据处理与结果投影的 P2 几何路径。

在这里插入图片描述

先看一下效果

下面是 Chen 划分上 YOLO26s-3d 的投影结果。模型除了给出普通 2D 检测框,还会结合每张图对应的 P2 相机投影矩阵,把预测的尺寸、深度、位置和朝向还原成图像上的 3D 包围盒。

在这里插入图片描述

这里有一个容易忽略的点:单目 3D 检测并不是在普通 YOLO 检测头后面随便多接几个回归量就结束了。只要训练阶段对图像做了缩放、LetterBox 或水平翻转,相机内参和投影矩阵也必须同步变化。否则 2D 框看起来可能正常,深度和三维位置却会持续受到错误监督。

这次实现花时间最多的部分,实际上就是把这条几何链路从数据加载、增强、损失、验证一直打通到导出和后处理。

这次开源包含什么

目前代码提供以下能力:

  1. 原生 detect3d train / val / predict / export 命令。
  2. YOLO11 和 YOLO26 的 Detect3D 模型配置,Release 主要提供轻量的 YOLO26n-3d 和 YOLO26s-3d 权重。
  3. 2D 检测、投影中心、直接深度、三维尺寸、MultiBin 朝向和三维定位质量的联合学习。
  4. 对 LetterBox、水平翻转和多尺度训练中的 P2 投影矩阵做同步更新。
  5. 通用旋转 3D IoU 指标、3D mAP、距离/位置/尺寸/朝向误差,以及 KITTI R40 的 AP3D、APBEV 和 AOS。
  6. PyTorch 与 ONNX Runtime 推理,预测结果中保留完整三维字段,并支持投影 3D 框。
  7. 独立的 Detect3D 训练曲线、验证图、KITTI R40 曲线和预测文本导出。

模型是怎么设计的

1. 继续使用 Ultralytics 的多尺度检测框架

YOLO26-3d 仍然使用 P3、P4、P5 三个尺度的特征。YOLO26 版本保留端到端 one-to-many / one-to-one 训练和 NMS-free 推理,2D 检测部分没有被替换成另一套框架。

在此基础上,每个尺度增加几何分支和朝向分支。公开推理接口为每个目标输出 8 个三维量:

center_x center_y depth sin(alpha) cos(alpha) height width length

其中:

  • center_x, center_y 是物体三维中心在图像上的投影位置;
  • depth 是相机坐标系中的深度;
  • alpha 是观察角,输出正弦和余弦可以减少角度边界的不连续;
  • height, width, length 是物体的实际三维尺寸,单位为米。

静态 ONNX 的最终输出形状是 1 x 300 x 14。14 个字段由 4 个 2D 框坐标、置信度、类别,以及上面的 8 个三维字段组成。

2. 投影中心采用 box-relative 表达

三维中心投影不是直接回归整张图上的绝对像素,而是相对于当前 2D 框中心和大小预测偏移。这样 P3、P4、P5 可以使用一致的监督定义,也能减少目标尺度变化带来的回归范围差异。

用公式写出来比较直观:

c 3 D i m g = c 2 D + Δ c ⊙ [ w 2 D h 2 D ] \mathbf{c}_{3D}^{img}=\mathbf{c}_{2D}+\Delta\mathbf{c}\odot \begin{bmatrix}w_{2D} & h_{2D}\end{bmatrix} c3Dimg=c2D+Δc[w2Dh2D]

得到投影中心和深度以后,再结合每张图片对应的 P2,就可以恢复相机坐标中的三维位置:

λ [ u v 1 ] = P 2 [ X Y Z 1 ] \lambda \begin{bmatrix}u\\v\\1\end{bmatrix} =P_2 \begin{bmatrix}X\\Y\\Z\\1\end{bmatrix} λ uv1 =P2 XYZ1

这里的 ( u , v ) (u,v) (u,v) 是三维中心在图像上的投影, ( X , Y , Z ) (X,Y,Z) (X,Y,Z) 是相机坐标, Z Z Z 对应模型预测的深度。

3. 深度、尺寸与朝向

  • 深度使用有界的直接对数深度回归,同时加入相机 Z 方向的辅助约束;
  • 三维尺寸使用类别均值作为先验,网络学习 h/w/l 的对数残差;
  • 朝向使用 12-bin MultiBin 分类与残差解码;
  • 训练阶段额外预测八个三维角点的投影偏移,用作上下文几何监督,这部分不会增加最终部署输出。

尺寸与旋转的解码可以概括为:

d = d ˉ c ⊙ exp ⁡ ( Δ d ) , r y = α + atan2 ⁡ ( X , Z ) \mathbf{d}=\bar{\mathbf{d}}_c\odot\exp(\Delta\mathbf{d}), \qquad r_y=\alpha+\operatorname{atan2}(X,Z) d=dˉcexp(Δd),ry=α+atan2(X,Z)

其中 d ˉ c \bar{\mathbf{d}}_c dˉc 是类别尺寸先验, α \alpha α 是观察角, r y r_y ry 是 KITTI 相机坐标系下的 rotation_y

4. 三维定位质量分数

同一个类别分数高,并不代表它的三维位置就一定可靠。因此模型还学习了一个 q3d 定位质量分数,并在推理时用它重新校准分类置信度。它的作用更接近排序器:优先保留三维几何更可信的候选,而不是只看 2D 分类分数。

s f i n a l = σ ( s c l s ) ⋅ σ ( q 3 d ) p s_{final}=\sigma(s_{cls})\cdot\sigma(q_{3d})^p sfinal=σ(scls)σ(q3d)p

当前默认 p = 0.5 p=0.5 p=0.5。这个分数主要用于候选排序,不会改变公开的 8 个三维输出字段。

5. 3D 监督门控

默认数据配置会对三维监督做以下限制:

min_3d_depth: 2.0
max_3d_depth: 65.0
min_3d_box_height: 25.0
max_3d_center_offset: 0.5

这些条件只决定目标是否参与 3D 分支训练。没有通过门控的目标仍然可以参与 2D 检测训练,不会被整个样本直接丢掉。

环境与安装

代码元数据支持 Python 3.8 及以上、PyTorch 1.8 及以上。实际使用建议选择较新的 Python、PyTorch 和 CUDA 组合,尤其是准备运行 ONNX Runtime GPU 时,CUDA、cuDNN 和 onnxruntime-gpu 的版本必须匹配。

Detect3D 代码目前位于单独的开源分支,PyPI 上的普通 ultralytics 还不包含这部分功能,因此这里直接克隆对应分支并做 editable install:

git clone --branch feat/detect3d --single-branch \
  https://github.com/Dorablank/ultralytics.git ultralytics-detect3d

cd ultralytics-detect3d
python -m pip install -U pip
python -m pip install -e .

需要导出 ONNX 时,可以再安装导出依赖:

python -m pip install -e ".[export-base]"

安装后先确认导入的是刚刚克隆的代码,而不是环境里原有的另一个 Ultralytics:

python -c "import ultralytics; print(ultralytics.__file__)"

输出路径应该位于当前 ultralytics-detect3d 目录中。

Release 里应该下载哪个权重

文件 数据划分 建议用途
chen-yolo26n-3d-seed0.pt Chen 3712/3769 优先用于快速复现、继续训练和 PT 推理
chen-yolo26s-3d-seed1.pt Chen 3712/3769 优先用于精度更高的 PT 验证和推理
chen-yolo26n-3d-seed0.onnx Chen 3712/3769 n 模型的静态 ONNX 部署
chen-yolo26s-3d-seed1.onnx Chen 3712/3769 s 模型的静态 ONNX 部署
kitti80-20-yolo26n-3d-seed2.* 分层 80/20 补充工程实验,不用于标准横向比较
kitti80-20-yolo26s-3d-seed0.* 分层 80/20 补充工程实验,不用于标准横向比较

第一次体验建议从 Chen n 权重开始。PT 适合验证完整功能和重新导出,ONNX 适合固定 416 x 1280 的部署测试。下载后可以用 Release 中的 SHA256SUMS 校验指定文件是否完整:

grep 'chen-yolo26n-3d-seed0.pt' SHA256SUMS | sha256sum -c -

KITTI 数据准备

1. 下载哪些内容

需要从 KITTI Vision Benchmark Suite 获取 Object Detection 数据中的:

  • 左相机彩色图像 image_2
  • 原始目标标注 label_2
  • 相机标定文件 calib

KITTI 数据没有打包进项目仓库或 Release,需要从数据集官网下载并按照官网要求使用。

2. 推荐使用 Chen 3712/3769 划分

KITTI 官方提供 7481 张带标注训练图,但没有为这部分数据指定单目 3D 训练/验证拆分。本文主结果使用单目 3D 研究中常见的 Chen split:

  • train:3712 张;
  • val:3769 张;
  • 两部分互不重叠。

如果需要复现实验,建议使用与基线一致、来源明确的 train.txtval.txt,这样后面的指标才更容易对齐。

Chen 训练集的类别分布如下。Car 数量明显更多,Pedestrian 和 Cyclist 相对少,这也是三类结果差异较大的原因之一。

在这里插入图片描述

整理后的目录结构如下:

kitti_mono3d/
├── train/
│   ├── images/      # 000000.png
│   ├── labels/      # 转换后的 12 列训练标签
│   ├── calib/       # 原始标定,必须包含 P2
│   └── label_2/     # 原始 KITTI 标签,不能覆盖
└── val/
    ├── images/
    ├── labels/
    ├── calib/
    └── label_2/

四个目录必须按文件名 stem 一一对应。例如 000134.png 对应:

images/000134.png
labels/000134.txt
calib/000134.txt
label_2/000134.txt

labelslabel_2 不能混用。前者是内部训练格式,后者必须保留 KITTI 官方原文,因为完整 R40 评估还需要其中的 truncation、occlusion 和 DontCare 信息。

3. 12 列训练标签

每个目标占一行:

class cx cy width height z x y width_3d height_3d length_3d rotation_y

字段含义如下:

字段 含义
class Car=0Pedestrian=1Cyclist=2
cx cy width height 裁剪到图像边界后再归一化的 2D 框
z x y KITTI 相机坐标系中的深度、横向位置和底面中心纵坐标,单位为米
width_3d height_3d length_3d 三维尺寸,单位为米
rotation_y 绕相机 Y 轴旋转的角度,单位为弧度

DontCare 和未映射的类别不写入内部训练标签。二维框在归一化前要先裁到图像边界,完全位于图像外或三维尺寸非法的目标应当跳过。

下面给出一个精简转换脚本。它假设 train/val 的图片、label_2calib 已按前面的目录结构放好,只负责生成内部 labels。可以保存为 convert_kitti_labels.py

from math import isfinite
from pathlib import Path

from PIL import Image


DATA_ROOT = Path("/absolute/path/to/kitti_mono3d")
CLASSES = {"Car": 0, "Pedestrian": 1, "Cyclist": 2}


def convert_split(split: str) -> None:
    split_root = DATA_ROOT / split
    image_dir = split_root / "images"
    source_dir = split_root / "label_2"
    output_dir = split_root / "labels"
    output_dir.mkdir(parents=True, exist_ok=True)

    for source in sorted(source_dir.glob("*.txt")):
        image_path = image_dir / f"{source.stem}.png"
        if not image_path.is_file():
            raise FileNotFoundError(image_path)

        with Image.open(image_path) as image:
            image_width, image_height = image.size

        output_rows = []
        for row in source.read_text(encoding="utf-8").splitlines():
            fields = row.split()
            if len(fields) < 15 or fields[0] not in CLASSES:
                continue

            class_id = CLASSES[fields[0]]
            truncated = float(fields[1])
            x1, y1, x2, y2 = map(float, fields[4:8])
            height_3d, width_3d, length_3d = map(float, fields[8:11])
            x, y, z = map(float, fields[11:14])
            rotation_y = float(fields[14])

            numeric_values = (truncated, x1, y1, x2, y2, height_3d, width_3d, length_3d, x, y, z, rotation_y)
            if not all(isfinite(value) for value in numeric_values):
                continue
            if truncated >= 1.0 or min(z, height_3d, width_3d, length_3d) <= 0.0:
                continue

            x1 = min(max(x1, 0.0), float(image_width))
            y1 = min(max(y1, 0.0), float(image_height))
            x2 = min(max(x2, 0.0), float(image_width))
            y2 = min(max(y2, 0.0), float(image_height))
            if x2 <= x1 or y2 <= y1:
                continue

            cx = (x1 + x2) / (2.0 * image_width)
            cy = (y1 + y2) / (2.0 * image_height)
            box_width = (x2 - x1) / image_width
            box_height = (y2 - y1) / image_height

            values = (
                class_id,
                cx,
                cy,
                box_width,
                box_height,
                z,
                x,
                y,
                width_3d,
                height_3d,
                length_3d,
                rotation_y,
            )
            output_rows.append(" ".join(f"{value:.10f}" if i else str(value) for i, value in enumerate(values)))

        (output_dir / source.name).write_text("\n".join(output_rows), encoding="utf-8")


for dataset_split in ("train", "val"):
    convert_split(dataset_split)

修改 DATA_ROOT 后运行:

python convert_kitti_labels.py

正式训练前建议检查四类文件数量和 stem 是否一致。Chen split 应分别有 3712 和 3769 张图:

find /path/to/kitti_mono3d/train/images -type f | wc -l
find /path/to/kitti_mono3d/val/images -type f | wc -l

4. 相机标定文件

每张图片对应的标定文件必须包含一行 P2

P2: p00 p01 p02 p03 p10 p11 p12 p13 p20 p21 p22 p23

代码会把这 12 个有限数值还原成 3 x 4 投影矩阵,并检查左侧 3 x 3 部分是否可逆。缺失或损坏的标定默认会使样本校验失败。

配置里也可以开启 allow_default_calib: true 使用默认 KITTI 标定,方便快速排查接口。正式训练和评估还是建议使用每张图片对应的真实标定。

5. 数据 YAML

新建 kitti3d.yaml

path: /absolute/path/to/kitti_mono3d
train: train/images
val: val/images

label_dir: labels
calib_dir: calib
kitti_label_dir: label_2

min_3d_depth: 2.0
max_3d_depth: 65.0
min_3d_box_height: 25.0
max_3d_center_offset: 0.5

names:
  0: Car
  1: Pedestrian
  2: Cyclist

转换结束后最好先抽样画几张训练图,确认 2D 框、类别和标定文件没有错位。下面是实际训练 batch 的可视化:

在这里插入图片描述

开始训练

YOLO26n-3d

yolo detect3d train \
  model=yolo26n-3d.yaml \
  pretrained=yolo26n.pt \
  data=/absolute/path/to/kitti3d.yaml \
  epochs=100 imgsz=1280 rect=True \
  batch=16 workers=6 device=0 \
  seed=0 deterministic=True cos_lr=True \
  patience=1000 save_period=10 \
  name=chen_yolo26n_3d_seed0

YOLO26s-3d

yolo detect3d train \
  model=yolo26s-3d.yaml \
  pretrained=yolo26s.pt \
  data=/absolute/path/to/kitti3d.yaml \
  epochs=100 imgsz=1280 rect=True \
  batch=16 workers=6 device=0 \
  seed=1 deterministic=True cos_lr=True \
  patience=1000 save_period=10 \
  name=chen_yolo26s_3d_seed1

这里的 pretrained 加载普通 YOLO26 2D 权重,匹配到的骨干和 2D 分支参数会被迁移,新增的 3D 分支从头训练。

rect=True 对 KITTI 很重要。KITTI 图像本身是宽画幅,没有必要为了 imgsz=1280 全部填充成 1280 x 1280。矩形批处理会根据长宽比和网络步长组织输入,实际常见张量尺寸是 416 x 1280

另外,训练日志中的 best.pt 是根据通用 metrics/mAP50(3D) 选择的,不是按 KITTI R40 选出的“R40 最优”。写论文或复现实验时最好把这两个概念分开。

训练过程中会单独绘制三维损失、通用 3D 指标和 2D 辅助指标。下面是 Chen YOLO26s-3d 的完整训练曲线:

在这里插入图片描述

验证与 KITTI R40

普通验证

只看 Ultralytics 通用 2D/3D 指标时,可以关闭 KITTI 评估:

yolo detect3d val \
  model=/path/to/best.pt \
  data=/absolute/path/to/kitti3d.yaml \
  split=val imgsz=1280 rect=True device=0 \
  kitti_eval=off plots=True

快速 R40

fast 只计算三个类别的 Moderate AP3D R40,适合训练结束后快速核对:

yolo detect3d val \
  model=/path/to/best.pt \
  data=/absolute/path/to/kitti3d.yaml \
  split=val imgsz=1280 rect=True device=0 \
  kitti_eval=fast plots=True

完整 R40

正式报告建议使用 full

yolo detect3d val \
  model=/path/to/best.pt \
  data=/absolute/path/to/kitti3d.yaml \
  split=val imgsz=1280 rect=True device=0 \
  kitti_eval=full plots=True

完整模式会计算 Easy / Moderate / Hard 下的 AP3D、APBEV 和 AOS,同时保存 KITTI 格式预测文本与曲线。

需要注意,通用 3D mAP 和 KITTI R40 不是同一个指标:

  • 通用 3D mAP 使用精确旋转 3D IoU,并报告 0.5:0.95 范围,数值通常是 0~1
  • KITTI R40 使用类别标准 IoU、难度分级、忽略规则和 40 个 recall 采样点,表格通常写成百分数;
  • AOS 反映方向相似度,不等价于 AP3D。

所以看到 metrics/mAP50(3D)=0.15Car AP3D=13.18 时,分别按照各自协议理解就好。

PyTorch 推理与三维可视化

单张图可以传一个标定文件:

yolo detect3d predict \
  model=/path/to/best.pt \
  source=/path/to/000134.png \
  calib=/path/to/000134.txt \
  imgsz=1280 rect=True device=0 \
  conf=0.25 save=True save_txt=True \
  project=runs/detect3d name=pt_predict

预测整个目录时,calib 也传目录。程序会按图片 stem 严格匹配:

yolo detect3d predict \
  model=/path/to/best.pt \
  source=/path/to/kitti_mono3d/val/images \
  calib=/path/to/kitti_mono3d/val/calib \
  imgsz=1280 rect=True device=0 \
  save=True project=runs/detect3d name=pt_predict_val

少一份同名标定文件会直接报错,这比静默使用错误相机参数更可靠。标定矩阵用于把预测反投影到相机坐标并绘制三维框,因此可视化时不建议省略。

Python API 也可以直接使用:

from ultralytics import YOLO


model = YOLO("/path/to/best.pt")
results = model.predict(
    source="/path/to/000134.png",
    calib="/path/to/000134.txt",
    imgsz=1280,
    rect=True,
    device=0,
    save=True,
)

result = results[0]
print(result.boxes.xyxy)       # 2D boxes
print(result.d3_params.data)   # N x 8 decoded 3D parameters
print(result.summary())        # summary 中包含 box3d 字段

导出 ONNX 并推理

1. 静态 416 x 1280 导出

yolo detect3d export \
  model=/path/to/best.pt \
  format=onnx imgsz='[416,1280]' \
  batch=1 opset=17 simplify=True dynamic=False

Release 中的 ONNX 都采用:

input : 1 x 3 x 416 x 1280
output: 1 x 300 x 14
opset : 17

如果部署端需要其他分辨率或动态 batch,请从 PT 重新导出,并设置合适的 imgszdynamic=True。不要把静态 416 x 1280 模型直接当作任意尺寸模型使用。

2. ONNX Runtime 推理

GPU 环境:

yolo detect3d predict \
  model=/path/to/best.onnx \
  source=/path/to/000134.png \
  calib=/path/to/000134.txt \
  imgsz='[416,1280]' device=0 \
  conf=0.25 save=True save_txt=True \
  project=runs/detect3d name=onnx_predict

CPU 环境把 device=0 改成 device=cpu。可以用下面的命令确认 ONNX Runtime 实际发现了哪些执行后端:

python -c "import onnxruntime as ort; print(ort.get_available_providers())"

4090 上如果只显示 CPUExecutionProvider,几秒一次推理并不奇怪;这说明 ONNX 没有真正跑在显卡上。正常的 GPU 环境至少应看到:

CUDAExecutionProvider
CPUExecutionProvider

另外,P2 标定矩阵不是 ONNX 网络输入。ONNX 负责输出 2D 框和 8 个三维参数,反投影与三维框绘制仍由 Ultralytics 后处理完成。

模型规模

模型 融合后层数 参数量 GFLOPs PT 大小 ONNX 大小
YOLO26n-3d 145 3.12M 8.1 8.1 MB 12.3 MB
YOLO26s-3d 145 10.73M 25.1 24.3 MB 41.3 MB

这里的 GFLOPs 是 640 x 640 模型统计口径,后面的 4090 延迟则使用 416 x 1280,二者输入尺寸不同,不要据此反推延迟。

Chen 3712/3769 主结果

下面是最终代码重新验证得到的 KITTI R40。数值单位为百分数,每格顺序都是 Easy / Moderate / Hard。类别标准 IoU 为 Car 0.7、Pedestrian/Cyclist 0.5。

模型 类别 AP3D APBEV AOS
YOLO26n-3d, seed 0 Car 12.87 / 9.67 / 8.13 21.18 / 15.76 / 13.58 94.23 / 86.18 / 79.73
YOLO26n-3d, seed 0 Pedestrian 5.84 / 4.65 / 3.69 6.79 / 5.52 / 4.51 50.26 / 42.76 / 37.91
YOLO26n-3d, seed 0 Cyclist 10.40 / 5.37 / 4.92 10.88 / 5.61 / 5.37 41.89 / 27.25 / 26.11
YOLO26s-3d, seed 1 Car 17.95 / 13.18 / 11.21 26.30 / 19.66 / 17.03 96.40 / 90.61 / 82.69
YOLO26s-3d, seed 1 Pedestrian 8.28 / 6.06 / 4.91 9.96 / 7.25 / 5.99 56.33 / 47.84 / 41.46
YOLO26s-3d, seed 1 Cyclist 10.31 / 5.10 / 4.96 10.96 / 5.72 / 5.39 47.60 / 31.24 / 30.03

三类 Moderate AP3D 的简单均值分别为:

  • YOLO26n-3d:6.56;
  • YOLO26s-3d:8.11。

这里报告的是 Chen 验证划分结果。n/s 使用了不同随机种子,表格更适合用来了解两个已发布权重的大致表现。

在这里插入图片描述

如果更关心某一个类别,也可以看完整的插值曲线。下面是 Car 在 IoU 0.7 下的 AP3D、APBEV 和 AOS R40:

在这里插入图片描述

通用 2D/3D 指标与误差

模型 Box mAP50 Box mAP50-95 3D mAP50 3D mAP50-95 距离 MAE 朝向 MAE
Chen n/seed0 0.541 0.338 0.139 0.0423 1.018 m 26.46°
Chen s/seed1 0.549 0.354 0.150 0.0478 0.920 m 20.49°

这里的 MAE 只统计“同类别且 2D IoU 大于等于 0.5 的一对一匹配”,它用于观察已经匹配目标的回归质量,不代表对全部 GT 的无条件误差。

80/20 划分的补充结果

项目还保留了一个确定性的分层 80/20 实验:把 7481 张图合并后,按单图类别计数分层,再用 seed 0 划成 5985 张训练图和 1496 张验证图。

这组数字会明显高于 Chen split,主要原因是相关帧或近重复场景可能跨过训练/验证边界。它更适合观察训练和部署流程是否工作正常,主结果仍以前面的 Chen split 为准。

模型 类别 AP3D APBEV AOS
YOLO26n-3d, seed 2 Car 65.57 / 51.89 / 46.44 74.00 / 58.89 / 53.25 98.60 / 98.03 / 95.36
YOLO26n-3d, seed 2 Pedestrian 24.60 / 19.84 / 17.69 27.09 / 22.01 / 19.80 89.25 / 84.34 / 81.18
YOLO26n-3d, seed 2 Cyclist 35.11 / 28.54 / 27.68 38.47 / 30.94 / 30.20 89.20 / 87.40 / 85.09
YOLO26s-3d, seed 0 Car 75.05 / 60.84 / 54.68 81.15 / 67.45 / 61.28 99.18 / 99.06 / 96.52
YOLO26s-3d, seed 0 Pedestrian 30.53 / 24.46 / 21.13 32.19 / 25.77 / 22.34 94.11 / 88.52 / 85.19
YOLO26s-3d, seed 0 Cyclist 42.91 / 36.41 / 34.66 45.39 / 38.43 / 36.54 91.87 / 91.44 / 91.05

三类 Moderate AP3D 均值为 n/seed2 的 33.42 和 s/seed0 的 40.57。

RTX 4090 速度

速度测试条件:batch 1、FP32、416 x 1280、预热 30 次、测量 200 次,同一张图片已经载入内存,不包含磁盘 I/O 和可视化。

模型架构 后端 推理 P50 / P90 Pipeline P50 Wall P50 推理 FPS Pipeline FPS
n PyTorch 5.925 / 6.168 ms 8.218 ms 8.380 ms 168.78 121.69
n ONNX CUDA 2.765 / 2.839 ms 5.045 ms 5.201 ms 361.68 198.20
s PyTorch 6.694 / 7.138 ms 8.973 ms 9.147 ms 149.39 111.44
s ONNX CUDA 3.017 / 3.158 ms 5.281 ms 5.442 ms 331.46 189.37

纯推理 P50 下,ONNX CUDA 对 n/s 分别约为 PyTorch 的 2.14 倍和 2.22 倍;如果把预处理和后处理也算进去,Pipeline 加速约为 1.63 倍和 1.70 倍。

这组速度使用相同 n/s 架构的 80/20 权重进行测量,主要反映模型规模与后端性能。原始 JSON/CSV 已放在 Release 中。

PT 和 ONNX 会不会有差异

会有很小的数值差异,这是正常现象。导出、算子融合以及不同后端的浮点实现都可能让置信度或框坐标在末位发生变化。如果某个目标刚好位于 conf 阈值附近,ONNX 比 PT 少一个框或多一个框并不奇怪。

排查时先确保以下条件一致:

  1. 使用同一张原图和同一个 imgsz
  2. confioumax_det 完全相同;
  3. PT 和 ONNX 都使用同一版后处理代码;
  4. 确认 ONNX 没有意外回退到 CPU;
  5. 静态模型输入严格为导出时的 416 x 1280

当前速度样例中,PT/ONNX 的检测数量中位数一致:n 都是 15,s 都是 17;ONNX checker 也已通过。不过这只能说明基本输出一致,不能理解为所有框和三维参数逐元素完全相等。

为什么日志里是 416 x 1280,而不是 1280 x 1280

imgsz=1280 通常表示目标长边,不代表所有图像都必须变成正方形。KITTI 图像大约是 3:1 的宽画幅,在矩形预处理和 stride 对齐后,常见输入就是 416 x 1280

这比 1280 x 1280 少了大量无效填充,训练和推理都会更省显存、更快。之所以常见的是 416 而不是 384,是因为程序先按 1280 长边缩放,再按照网络步长向上对齐高度;KITTI 不同图片的原始尺寸也并不完全相同。

显式指定 384 x 1280 并不是错误,但它会采用另一套缩放/填充尺寸,导出的静态 ONNX 也必须重新生成。两者没有脱离具体模型和数据的绝对优劣。为了让训练、最终验证、速度测试和 Release ONNX 保持同一口径,本文统一使用 416 x 1280

数据增强为什么需要谨慎

普通 2D 检测中,随便加一个空间增强,通常只要同步变换 2D 框即可。单目 3D 还依赖相机投影矩阵,所以任何几何变换都必须同步更新 P2

当前 Detect3D 支持已经接入几何链路的 LetterBox、水平翻转和多尺度预处理,也允许不改变空间几何的光度增强。自定义 Albumentations 空间操作如果不能同步修改投影矩阵,数据集会直接拒绝,而不是带着错误监督继续训练。

已完成的兼容性与发布检查

发布前针对 Detect3D、几何、损失、绘图、KITTI R40 和 ONNX 往返做了 120 项专项测试,并完成了:

  • Python 3.8 / PyTorch 1.8 旧环境兼容;
  • Ubuntu、Windows、macOS 和 ARM 测试;
  • PyTorch 完整验证与三维可视化;
  • opset 17 ONNX 导出和 ONNX checker;
  • ONNX Runtime CUDA 推理;
  • n/s 两个 Chen 权重的最终代码重新验证。

发布的 n/s 权重都用最终代码重新跑过验证、预测和 ONNX 导出。训练参数、逐 epoch 日志、曲线、速度 JSON/CSV 和 SHA256 校验值也一起放在 Release 中,复现时可以直接查。

常见问题

1. 能不能只传图片,不传 calib?

网络仍能给出 8 个三维预测字段,但没有正确 P2 就无法可靠地恢复相机坐标位置,也无法画出正确的投影三维框。正式推理建议始终提供与图片匹配的标定文件。

2. 能直接使用 Release 里的 ONNX 做其他尺寸吗?

不能。发布的 ONNX 是静态 1 x 3 x 416 x 1280。其他尺寸请从 PT 重新导出,或者明确使用 dynamic=True

3. 为什么 ONNX 在 4090 上还是几秒一张?

先看日志是否写着 CPUExecutionProvider。如果 CUDA/cuDNN 与 onnxruntime-gpu 不匹配,ONNX Runtime 很可能回退到 CPU。GPU 型号本身并不能保证 ONNX 自动跑在 GPU 上。

写在最后

这次更想解决的是单目 3D 的完整工程流程:能训练、能验证、能画三维框、能导出,也能在 ONNX Runtime 中把结果保存下来。

如果你正在做 KITTI 单目 3D、轻量部署,或者希望在现有 YOLO 工程里增加深度、尺寸和朝向预测,可以直接从 n 模型开始跑通流程,再根据显存和精度需求切换到 s。遇到可复现的问题时,建议附上运行命令、环境版本、输入尺寸、标定文件和完整日志,这样定位会快很多。

源码和全部发布资产:

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐