PyTorch 结果正常,导出 ONNX 后为什么又慢又不准?

训练完成的 YOLO 模型在 PyTorch 中检测准确,导出 ONNX、接入 C++ 后却出现了两个问题:检测框整体偏移,INT8 文件虽然缩小到原来的四分之一,推理耗时却几乎没变。模型能被 ONNX Runtime 正常加载,onnx.checker 也没有报错,那么问题究竟出在哪里?

这两个现象很容易被笼统归因于“ONNX 精度损失”或“Runtime 优化不好”,但 ONNX 导出并不会自动替部署端定义颜色顺序、letterbox、输出字段和 NMS,也不能凭空为目标 CPU/NPU 创造高效 INT8 kernel。问题通常藏在输入输出契约、前后处理复现、Execution Provider(执行提供程序,简称 EP)的图划分、线程配置或性能统计口径中。

本文沿着一条可以逐层证伪的链路展开:先固定模型契约,再用最小模型完成“PyTorch → ONNX checker → ONNX Runtime”数值闭环;随后才进入 C++、图优化、EP、量化和端到端 benchmark。读完后,你应该能判断差异最早出现在哪一层,而不是一上来反复调整置信度阈值。

ONNX 解决的是模型计算图的可移植性;ONNX Runtime 负责加载、图优化和执行;真正的部署效果取决于输入输出契约、算子支持、Execution Provider、线程策略、内存访问,以及前处理和后处理的端到端成本。

1. 部署链路总览

一条比较完整的部署链路通常长这样:

1
2
3
4
5
6
7
8
9
10
11
PyTorch 训练模型
↓
导出 ONNX
↓
校验模型结构、输入输出、数值一致性
↓
ONNX Runtime 图优化 / Execution Provider 调度
↓
C++ 封装:预处理、推理、后处理、异常处理、计时
↓
Linux / Windows / Android 部署

这里要先区分几件事:

层次 主要解决的问题 常见工具
ONNX 描述模型计算图,让模型脱离训练框架 torch.onnx.export、Ultralytics export
ONNX checker 检查 ONNX 模型结构是否合法 onnx.checker.check_model
ONNX Runtime 加载模型、图优化、执行推理 Python / C / C++ API
Execution Provider 把部分或全部节点交给特定硬件执行 CPU、CUDA、TensorRT、OpenVINO、NNAPI、QNN
业务推理程序 复现预处理、解析输出、NMS、跟踪、绘制、计时 OpenCV、C++、JNI、平台 UI

部署时最容易犯的错误,是只关心“ONNX 能不能加载”,却没有把“训练时送进模型的 Tensor”和“C++ 侧真正送进模型的 Tensor”对齐。

2. 导出前先确定模型契约

ONNX 导出后,必须明确模型的输入输出定义。部署端不是简单地“读取图片后送入模型”,而是要复现验证或推理阶段输入模型的最终 Tensor。

训练阶段的 Mosaic、MixUp、随机裁剪、颜色扰动等数据增强不应该带到部署端;部署端应该复现验证/推理阶段的固定预处理。

建议为每个模型保存一份模型契约,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"input_name": "images",
"input_shape": [1, 3, 736, 1280],
"input_dtype": "float32",
"layout": "NCHW",
"color_order": "RGB",
"normalize": "x / 255.0",
"resize_mode": "letterbox",
"pad_value": 114,
"output_name": "output0",
"output_format": "xyxy_score_class",
"nms": "outside_onnx"
}

部署端至少要确认这些内容:

项目 常见部署要求
输入尺寸 例如 [1, 3, 736, 1280]
Batch 实时视频推理通常为 1
数据类型 常见为 float32
数据排列 常见为 NCHW
图像颜色 常见为 RGB,但以训练/验证代码为准
数值范围 常见为 [0, 1],即 /255.0
Resize 方式 检测模型常见为 letterbox 保比例缩放
Padding 值 YOLO 常见为 114
输出格式 例如 [x1, y1, x2, y2, score, class_id]
NMS 位置 在 ONNX 内,或在 C++ 后处理内

2.1 BGR / RGB 不是小问题

OpenCV 默认读取图像是 BGR:

1
2
3
cv::imread()
↓
BGR 格式

但多数 YOLO 推理代码最终送入模型的是 RGB。典型预处理链路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
OpenCV BGR 图像
↓
letterbox
↓
BGR → RGB
↓
HWC → CHW
↓
uint8 → float32
↓
除以 255.0
↓
[1, 3, H, W]

C++ 中常见写法:

1
2
cv::Mat rgb;
cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB);

但不要机械地认为“所有模型都必须 BGR 转 RGB”。正确原则是:

以训练和验证代码中真正送入模型的 Tensor 为标准。训练侧最终是 RGB,部署侧就用 RGB;训练侧明确使用 BGR,部署侧就不要额外转换。

2.2 HWC、CHW、NCHW

OpenCV 图像通常是:

1
HWC = [height, width, channel]

大部分 ONNX 检测模型输入是:

1
NCHW = [batch, channel, height, width]

转换关系是:

1
2
3
4
5
6
7
HWC
↓
CHW
↓
增加 batch 维度
↓
NCHW

如果忘记转换,模型可能不报错,但检测结果通常会完全错误。

2.3 Letterbox 与坐标回映射

Letterbox 的作用是:

1
2
3
保持原图宽高比
+ 缩放至目标尺寸
+ 填充边缘

设原图大小为:

1
H0 × W0

模型输入大小为:

1
Hin × Win

缩放比例:

1
r = min(Win / W0, Hin / H0)

缩放后尺寸:

1
2
W' = round(W0 × r)
H' = round(H0 × r)

填充:

1
2
pad_x = (Win - W') / 2
pad_y = (Hin - H') / 2

模型输出框若位于 letterbox 后图像坐标系,映射回原图时要先减 padding,再除缩放比例:

1
2
x_original = (x_model - pad_x) / r
y_original = (y_model - pad_y) / r

常见错误是只除以缩放比例,但忘记减去 padding。结果会表现为检测框整体偏移,边缘目标定位异常。

2.4 如何建立一个最小的“导出—校验—对比”闭环

先不要用 YOLO 排查导出工具链。检测模型同时包含复杂输出和后处理,失败时很难判断是 exporter、Runtime 还是 NMS。下面先用一个小型分类模型验证基础链路。

示例面向 Python 3.10+、PyTorch 2.6+ 的 torch.export-based ONNX exporter,并需要 onnx、onnxruntime 和 numpy。具体可用版本应由项目锁文件固定;PyTorch exporter API 与 Runtime 支持的 opset 都可能演进,需要结合实际版本验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import numpy as np
import onnx
import onnxruntime as ort
import torch
from torch import nn

class TinyClassifier(nn.Module):
def __init__(self) -> None:
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 4, kernel_size=3, padding=1),
nn.ReLU(),
nn.AdaptiveAvgPool2d((1, 1)),
)
self.classifier = nn.Linear(4, 2)

def forward(self, images: torch.Tensor) -> torch.Tensor:
features = self.features(images).flatten(1)
return self.classifier(features)

torch.manual_seed(7)
model = TinyClassifier().eval()
example = torch.rand(1, 3, 224, 224, dtype=torch.float32)

# 这里只让 batch 动态;通道和空间尺寸仍属于模型契约。
batch = torch.export.Dim("batch", min=1, max=8)
onnx_program = torch.onnx.export(
model,
(example,),
input_names=["images"],
output_names=["scores"],
dynamic_shapes={"images": {0: batch}},
opset_version=18,
dynamo=True,
)
onnx_program.save("tiny_classifier.onnx")

# 第一层:格式和图结构校验。
onnx_model = onnx.load("tiny_classifier.onnx")
onnx.checker.check_model(onnx_model, full_check=True)

# 第二层:同一输入在 PyTorch 与 ORT 上比较原始输出。
sample = torch.rand(3, 3, 224, 224, dtype=torch.float32)
with torch.inference_mode():
expected = model(sample).cpu().numpy()

session = ort.InferenceSession(
"tiny_classifier.onnx",
providers=["CPUExecutionProvider"],
)
actual = session.run(
["scores"],
{"images": sample.cpu().numpy()},
)[0]

np.testing.assert_allclose(actual, expected, rtol=1e-4, atol=1e-5)
print("input:", session.get_inputs()[0].shape)
print("output:", actual.shape)
print("PyTorch and ONNX Runtime outputs match")

预期输出的具体动态维名称可能随 exporter 版本变化,但应能看到 batch 维不是固定的 1,并得到:

1
2
output: (3, 2)
PyTorch and ONNX Runtime outputs match

这里有四个关键设计:eval() 固定 Dropout、BatchNorm 等推理行为;示例输入用于捕获图和约束,不是“随便占个位”;动态 batch 明确限制在 1~8;数值比较发生在原始输出上,还没有混入后处理。

opset_version=18 只是这个示例选择的部署契约,不代表永远应该使用 18。正确做法是选择 exporter、目标 ONNX Runtime 和目标 EP 共同支持的 opset,并在升级任一组件后重新验证。

动态维度也不是越多越好。固定输入尺寸通常更容易获得稳定的内存规划和硬件优化;只有业务确实需要多 batch 或多分辨率时,才应把对应维度声明为动态。对于新版 dynamo=True exporter,优先使用 dynamic_shapes;旧版 exporter 常见的 dynamic_axes 语法不要未经版本确认直接混用。

3. YOLO 输出不能靠猜

不同 YOLO 版本、不同导出参数、不同后处理策略,ONNX 输出可能完全不同。

常见形式一:

1
[1, 300, 6]

可能表示:

1
[x1, y1, x2, y2, score, class_id]

这种输出通常说明模型图里已经完成了候选框筛选,甚至可能已经包含 NMS。

常见形式二:

1
[1, 84, 8400]

可能表示:

1
2
3
4 个边界框参数
+ 80 个类别分数
× 8400 个候选位置

这类输出一般需要在 C++ 中自行完成:

1
2
3
4
decode
score threshold
class selection
NMS

导出时一定要确认 NMS 位置:

1
2
3
4
5
6
7
nms=False:
ONNX 输出原始预测
C++ 执行后处理和 NMS

nms=True:
ONNX 图可能已经包含 NMS
C++ 不应重复执行 NMS

不要写“通用 YOLO 后处理”。更可靠的说法是:

只写适配当前模型输出结构的后处理,并把输出 shape、字段含义、NMS 位置写进模型契约。

4. 导出后的三层校验

导出 ONNX 后,不要直接进入 C++ 集成。建议按三层校验推进。

4.1 ONNX 结构校验

1
2
3
4
import onnx

model = onnx.load("model.onnx")
onnx.checker.check_model(model, full_check=True)

主要检查:

1
2
3
4
5
模型格式是否合法
节点连接是否正确
opset 是否兼容
输入输出定义是否完整
shape 推断是否存在问题

注意:结构合法不代表部署结果正确。它只能说明这个 ONNX 文件在格式和图结构上基本可用。

4.2 Python ONNX Runtime 校验

先用 Python 加载 ONNX,打印模型元信息并跑一次前向:

1
2
3
4
5
6
7
8
9
import onnxruntime as ort

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

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

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

目标是确认:

1
2
3
4
ONNX 能被 Runtime 正常加载
输入名、输出名、shape、dtype 正确
模型前向推理可以运行
输出结构符合预期

4.3 Python 与 C++ 数值一致性校验

对同一张固定图片,分别从 Python 和 C++ 保存:

1
2
3
input_tensor.npy
raw_output.npy
detections.json

比较顺序应该是:

1
2
3
1. Python input tensor 与 C++ input tensor 是否接近
2. Python raw output 与 C++ raw output 是否接近
3. 最终框坐标、类别、置信度是否接近

优先比较输入 Tensor,而不是只看最终检测框。

最终检测结果不同,可能来自很多环节:

1
2
3
4
5
6
7
8
BGR/RGB 错误
resize 方式不同
padding 不同
HWC/CHW 错误
归一化错误
输出解析错误
NMS 差异
坐标回映射错误

如果输入 Tensor 已经不一致,后面任何模型输出对比都没有意义。

5. ONNX Runtime C++ 侧怎么配置

C++ 侧通常从 Ort::SessionOptions 开始配置。

1
2
3
4
5
6
7
8
Ort::SessionOptions options;

options.SetGraphOptimizationLevel(
GraphOptimizationLevel::ORT_ENABLE_ALL
);

options.SetIntraOpNumThreads(4);
options.SetInterOpNumThreads(1);

常见可控制项包括:

1
2
3
4
5
6
7
图优化等级
单个算子内部并行度
算子图之间的并行策略
Execution Provider
日志等级
profiling
内存分配与 Arena 策略

通常建议在 benchmark 中显式设置图优化等级,而不是依赖默认行为。这样不同机器、不同构建、不同 ONNX Runtime 版本之间更容易对比。

图优化等级常见有:

级别 含义
ORT_DISABLE_ALL 关闭图优化
ORT_ENABLE_BASIC 常量折叠、冗余节点消除等基础优化
ORT_ENABLE_EXTENDED Basic + 更复杂的节点融合
ORT_ENABLE_ALL Basic + Extended + Layout Optimization 等更激进优化

实践中可以对比:

1
2
3
FP32 + ORT_ENABLE_EXTENDED
FP32 + ORT_ENABLE_ALL
INT8 + ORT_ENABLE_ALL

但不要只改一个配置就宣布“优化成功”。每个设置都要放到分段计时和同条件 benchmark 里验证。

6. 图优化、kernel 优化、EP 和量化的区别

很多部署讨论会把“图优化”“算子融合”“kernel 优化”“Execution Provider”“量化”混在一起。实际它们解决的问题不同。

6.1 图优化

图优化是改写 ONNX 计算图。

例如训练图里常见:

1
Conv → BatchNorm → ReLU

推理阶段 BatchNorm 参数固定后,可以融合成:

1
Conv' → ReLU

收益是:

1
2
3
4
减少节点数量
减少中间 Tensor
减少内存读写
降低调度开销

如果你在图里看不到 BatchNorm,可能是模型导出前已经完成了 BN folding,也可能是图结构不满足融合条件。

6.2 算子融合

常见融合包括:

1
2
3
4
5
Conv + BatchNorm
Conv + Add
Conv + Relu
MatMul + Add
Gemm + Activation

本质是把多个节点合并为一个融合节点,或者让 EP 使用一个融合 kernel 执行。

收益通常来自:

1
2
3
4
减少中间结果写回内存
减少算子调度
提高缓存利用率
降低访存开销

6.3 Kernel 优化

ONNX 的 Conv 只描述“做什么”,不描述“怎么在当前硬件上高效执行”。

同一个 Conv,不同硬件上的底层实现可能是:

1
2
3
4
5
6
7
CPU AVX2 Conv kernel
CPU AVX-512 / VNNI INT8 kernel
ARM NEON kernel
CUDA kernel
TensorRT kernel
NNAPI kernel
QNN HTP kernel

所以一个模型是否快,不只取决于 ONNX 图本身,还取决于当前平台有没有高效 kernel。

6.4 Execution Provider

Execution Provider 决定哪些节点交给哪种硬件或推理后端执行。

常见后端:

后端 典型平台
CPUExecutionProvider Linux、Windows、Android
CUDAExecutionProvider NVIDIA GPU
TensorRTExecutionProvider NVIDIA 高性能推理
OpenVINOExecutionProvider Intel CPU / iGPU
XNNPACK Android / ARM CPU
NNAPI Android GPU / NPU / DSP
QNN Qualcomm Snapdragon GPU / HTP / NPU
DirectML Windows GPU

Runtime 会把 ONNX 图拆成子图:

1
2
3
4
5
6
7
支持的节点
↓
交给 CUDA / TensorRT / NNAPI / QNN 等 EP

不支持的节点
↓
回退 CPU

如果模型被切得太碎:

1
2
3
4
5
6
7
CPU
↓
GPU/NPU
↓
CPU
↓
GPU/NPU

频繁的数据搬运会抵消硬件加速收益,甚至让速度变慢。

先确认当前安装包真正包含哪些 EP,并显式指定优先级:

1
2
3
4
5
6
7
8
9
10
import onnxruntime as ort

requested = ["CUDAExecutionProvider", "CPUExecutionProvider"]
available = set(ort.get_available_providers())
missing = [provider for provider in requested if provider not in available]
if missing:
raise RuntimeError(f"execution providers unavailable: {missing}")

session = ort.InferenceSession("model.onnx", providers=requested)
print("registered providers:", session.get_providers())

顺序表示优先级:CUDA 能接管的节点优先交给 CUDA,其余节点可以回退 CPU。但 session.get_providers() 只证明 EP 已注册,不能证明整张图都在该设备执行。要确认真实分配,应开启 ONNX Runtime profiling 或详细日志,运行具有代表性的输入,再检查各节点的 provider、子图边界和跨设备复制。

同样,SetIntraOpNumThreads() 等 CPU 线程选项主要影响 CPU 执行路径,不应假定 CUDA、TensorRT、QNN 等 EP 会以相同方式响应。每个 EP 的选项和版本支持都要以对应文档与实测为准。

7. INT8 为什么可能只变小、不变快

INT8 量化不是简单地把模型文件压缩小,而是尝试把部分浮点计算改为整数计算。

FP32 权重:

1
w = 0.123456

INT8 量化后通常保存:

1
2
3
q: int8 数值
scale: 缩放因子
zero_point: 零点

近似恢复:

1
x_fp32 ≈ scale × (x_int8 - zero_point)

卷积量化概念上接近:

1
2
3
4
5
6
X_int8 = Quantize(X_fp32)
W_int8 = Quantize(W_fp32)

Y_int32 = ConvInt8(X_int8, W_int8)

Y_fp32 = Dequantize(Y_int32)

重点是:

1
2
3
4
输入激活可能是 INT8
卷积权重可能是 INT8
累积结果通常使用 INT32
输出可能保留 INT8,也可能反量化回 FP32

因此:

1
2
3
模型文件变小
≠ 一定使用 INT8 算子
≠ 一定推理变快

7.1 Dynamic / Static / QAT

方法 特点 适用场景
Dynamic Quantization 推理时动态计算激活量化参数 Transformer、RNN 较常见
Static Quantization 用校准集提前确定激活范围 CNN、YOLO、检测模型常见
QAT 训练时模拟量化误差 PTQ 精度下降明显时

YOLO 这类 CNN 检测模型通常优先尝试 Static INT8 Quantization。

校准集要覆盖真实场景:

1
2
3
4
5
6
7
不同光照
不同天气
不同距离
不同目标密度
小目标
遮挡目标
低对比度目标

不要只用少量简单图片做校准,否则可能导致:

1
2
3
4
小目标 Recall 降低
暗光目标漏检
低置信目标被过滤
类别置信度波动

7.2 QOperator 与 QDQ

QOperator 是把原始算子替换成明确的量化算子:

1
2
3
Conv
↓
QLinearConv

QDQ 是在原始算子前后插入量化和反量化节点:

1
2
3
4
5
6
7
FP32 tensor
↓ QuantizeLinear
INT8 tensor
↓ DequantizeLinear
FP32 tensor → Conv
↓ QuantizeLinear / DequantizeLinear
下一层

从标准 ONNX 图表面看,普通 Conv 仍位于 Q/DQ 边界之间;支持该模式的 Runtime/EP 会识别这些节点并选择量化 kernel。不能仅凭文件里出现 QuantizeLinear 就断言卷积已经在目标硬件上以 INT8 执行。

理想情况下,连续层都能被 INT8 kernel 接住:

1
2
3
4
5
6
7
INT8 Conv
↓
INT8 Activation
↓
INT8 Conv
↓
少量 DeQuantize

不理想的情况是频繁往返:

1
2
3
4
5
6
7
8
9
FP32
↓
Quantize
↓
INT8 Conv
↓
DeQuantize
↓
FP32

这时量化/反量化、内存访问与数据转换开销可能抵消 INT8 的收益。

7.3 INT8 无明显加速的常见原因

原因 说明
没有高效 INT8 kernel Runtime 没有用到真正高性能的 INT8 算子实现
算子量化不完整 只有部分 Conv 量化,其他仍是 FP32
Q/DQ 太多 Quantize 和 DeQuantize 本身有额外开销
动态量化开销 每次推理都要计算激活范围
CPU 指令集不够强 不支持高效 INT8 SIMD 指令
batch=1 调度、内存和线程开销占比更大
模型较小 算子计算量不足,INT8 优势不明显
前后处理占比高 Resize、NMS、ByteTrack、光流才是瓶颈
线程过度竞争 ORT、OpenCV、视频解码同时抢 CPU
EP 回退 部分节点回退到 CPU,出现跨设备数据搬运

INT8 的价值不是“文件变小”,而是:

目标平台能否把连续计算子图交给高效 INT8 kernel 执行。

8. 性能测试必须分段

不要只看 FPS。FPS 是结果,不是诊断工具。

建议至少拆分:

1
2
3
4
5
6
7
decode_ms
preprocess_ms
onnx_ms
postprocess_ms
tracking_ms
render_ms
total_ms

推荐输出类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Frames: 962

Preprocess:
mean = 8.4 ms
p50 = 8.0 ms
p95 = 11.2 ms

ONNX:
mean = 245.3 ms
p50 = 240.1 ms
p95 = 278.6 ms

Postprocess:
mean = 2.1 ms
p50 = 2.0 ms
p95 = 3.7 ms

Tracking:
mean = 1.6 ms
p50 = 1.5 ms
p95 = 2.8 ms

Total:
mean = 257.4 ms
FPS = 3.88

这样才能判断:

1
2
3
4
5
INT8 是否真的减少了模型推理时间
图优化是否有效
线程数是否导致竞争
光流和 ByteTrack 是否成为新瓶颈
视频解码是否占用过高

8.1 CPU 部署优化顺序

第一阶段,保证正确性:

1
2
3
4
5
6
7
8
9
BGR/RGB 正确
letterbox 正确
padding 正确
HWC → CHW 正确
float32 与 /255 正确
输入 shape 正确
输出结构正确
坐标映射正确
NMS 不重复执行

第二阶段,建立 FP32 基线:

1
2
3
4
5
6
同一模型
同一视频
同一线程配置
同一 warmup 次数
同一置信度阈值
同一 NMS 阈值

记录:

1
2
3
4
5
6
7
P50 延迟
P95 延迟
平均 FPS
CPU 利用率
Precision
Recall
F1

第三阶段,比较图优化与线程数:

1
2
3
4
5
6
7
8
9
FP32 + ORT_ENABLE_EXTENDED
FP32 + ORT_ENABLE_ALL

intra_op = 2
intra_op = 4
intra_op = 6

推理 worker = 1
推理 worker = 2

注意避免线程过度竞争:

1
推理 worker 数 × intra_op threads

不应明显大于机器实际物理核心数。

第四阶段,再测试 Static INT8:

1
2
FP32 ONNX
INT8 ONNX

不仅比较速度,还要比较:

1
2
3
4
5
6
7
8
9
模型大小
内存占用
ONNX 推理耗时
端到端耗时
Precision
Recall
F1
小目标 Recall
P50 / P95 延迟

9. 跨平台部署的组织方式

在目标 Runtime/EP 支持相应 opset、算子和数据类型的前提下,同一个 ONNX 文件通常可以复用于:

1
2
3
Linux x86_64
Windows x64
Android arm64-v8a

但不同平台需要分别打包 Runtime 库并选择 EP:

1
2
3
4
5
6
7
8
Linux:
app + libonnxruntime.so

Windows:
app.exe + onnxruntime.dll

Android:
APK/AAR + libonnxruntime.so + JNI/C++ 核心代码

建议复用核心层:

1
2
3
4
5
6
7
core/
├── ort_runner.cpp
├── preprocess.cpp
├── yolo_postprocess.cpp
├── detector.cpp
├── byte_tracker.cpp
└── optical_flow.cpp

平台层单独实现:

1
2
3
4
platform/
├── linux_main.cpp
├── windows_main.cpp
└── android_jni.cpp

原则是:

模型逻辑、预处理、后处理、跟踪逻辑尽量跨平台复用;平台差异集中在编译、依赖加载、摄像头输入、UI 和硬件 EP 配置。

10. VisionTrack 已发布实现的部署实践

本节于 2026-10-03 对照 Yolo 已发布提交 d8688e3 核对。项目展示名称为 VisionTrack / YOLO Tracking;训练环境负责 YOLO26 训练与导出,线上 C++ 进程使用 ONNX Runtime 或 OpenVINO,不依赖 Python 推理运行时。

10.1 模型契约与启动入口

当前 YOLO26 导出输出为 output0(1, 300, 6),每行按 x1, y1, x2, y2, score, class_id 解析;C++ 解码器还保留旧式候选输出的兼容分支,端到端输出后仍执行兼容 NMS。是否跳过 NMS,需要拥挤道路场景的 A/B 回归。

导出尺寸按高、宽传入,高模型使用 --imgsz 736 1280。当前导出脚本默认 opset 13 并进行 INT8 校准;仅导出 FP32 时加 --skip-int8。输出使用新目录,脚本拒绝覆盖已有导出文件。

Linux 构建入口如下,前提是按仓库说明准备 GCC15、vcpkg 依赖和本地模型文件;这些路径不适用于未经配置的任意机器。

1
2
3
4
cd yolo_onnx_cpp
cmake --preset vcpkg-gcc15-release
cmake --build --preset vcpkg-gcc15-release
../build/yolo_api config.yaml

模型权重不会由服务自动下载。前端在另一个终端进入 front/ 执行 npm ci 和 npm run dev,默认代理到 127.0.0.1:8080。

10.2 上传与多路实时流的资源边界

1
2
上传视频 -> 临时文件 -> 有界 VideoJobExecutor -> 检测 / 跟踪 -> 完整 JSON
实时流 -> /streams 注册 -> 共享 InferenceScheduler -> 跟踪 / 回放校正 -> SSE

上传视频通过 /infer_video 或 /infer_video_high_low 进入有界任务池,对调用方仍等待完整响应;池满返回 503。实时流创建后独立运行,多路共享模型调度资源,每路维护跟踪与结果状态。异步模式需要配置及共享调度器支持;本地文件、关闭异步或调度器不可用时走同步回退。

接口 当前含义
POST /infer 图片检测,multipart 字段为 image
POST /infer_video、/infer_video_high_low 上传视频,字段为 video,一次性返回 JSON
POST /streams JSON 提交 stream_id 和 source,成功返回 201
GET /streams、/streams/{id} 流列表与状态
DELETE /streams/{id} 停止流并释放注册名额
GET /streams/{id}/events detection / end SSE,支持 Last-Event-ID 补发
GET /metrics Prometheus 流、调度器、队列与门禁指标

每路最多缓存 256 条事件,游标过旧或超前时发送 error 并关闭连接;状态不会在进程重启后恢复。上传结果也没有持久化 job API,响应分页参数只裁剪序列化范围,不减少已经完成的推理工作。

Web 工作台当前只调用 /infer 和 /infer_video,High/Low 与实时流管理尚未接入。/health 检查存活,/ready 只检查流管理器是否存在,均不能证明摄像头连通、检测质量或吞吐达标。

10.3 CPU 后端基准需要保留测试条件

2026-09-03 C++ 合并报告 使用同一套 C++ 预处理和相同 ONNX 模型,比较 ONNX Runtime 1.23.2 与 OpenVINO 2026.1.0。主机为 Xeon Gold 5218,32 个物理核心;单实例、batch=1、同步 CPU 推理,每个组合 150 个正式样本,总计 3000 个样本。

模型输入尺寸 ORT 最优均值 OpenVINO 最优均值 各自最优配置的比值
1280 × 736 137.38 ms,16 线程 91.98 ms,16 线程 1.49x
640 × 384 46.23 ms,16 线程 22.90 ms,16 线程 2.02x

表中是纯后端推理耗时,完整 infer 与端到端成本另行统计。大模型 OpenVINO 8 线程 P95 为 114.28 ms,低于 16 线程的 146.99 ms;最低均值和稳定尾延迟会导向不同配置。双模型、多路流和上传并发要重新压测,不能把单实例结果直接换算成当前系统吞吐。

10.4 INT8 验证已经暴露过运行时差异

2026-09-10 量化对比 使用 C++ 对齐的 BGR letterbox、RGB NCHW float32 输入,CPU 8 线程,IoU=0.5,并对同类 crowd 重叠预测使用统一忽略规则。

模型与后端 低阈值候选 mAP50 均值推理耗时
FP32 / OpenVINO 0.471531 38.522 ms
legacy INT8 / OpenVINO 0.020518 23.071 ms
legacy INT8 / ONNX Runtime 0.437558 100.053 ms
u8s8 per-channel / OpenVINO 0.450545 23.005 ms

mAP50 对应报告中 score=0.001 的候选下限,其他阈值是运行点;耗时不包含解码、预处理、decode 与 NMS。旧 INT8 在 OpenVINO 中的精度下降,以及相同旧模型在 ORT 中更慢的结果,都说明“INT8”标签不能替代具体后端验证。调整量化表示后也仍有精度差异,需要按业务接受标准评估。

这组人工标注评估与 2026-07-10 的三场景 full high-res 伪标签回归不同。后者 F1=0.7726、FP 降低约 63%,不能与本表拼成同一个质量结论。多路长测的成功和失败记录见 实时监测实施记录,真实 RTSP 与生产业务闭环仍需单独验收。

11. 回到开头:框为什么偏,INT8 为什么不快?

检测框整体偏移时,最先比较的应是 Python 与 C++ 的输入 Tensor、letterbox 参数和原始输出,而不是怀疑 ONNX “改变了模型”。INT8 不加速时,则应确认量化节点是否形成连续子图、目标 EP 是否真正接管、硬件是否有高效整数指令,以及端到端瓶颈是否根本不在模型推理。

结构校验通过只能证明模型图基本合法,模型文件缩小也只能证明参数表示发生了变化。它们都不能替代数值对比、节点分配证据和同条件 benchmark。

遇到任何“优化方法”,先问四个问题:

1
2
3
4
5
6
7
1. 它减少的是模型大小,还是实际计算量?

2. 它减少的是算子数量,还是内存访问?

3. 当前硬件是否存在对应的高效 kernel?

4. 它优化的是单次 ONNX 推理,还是端到端视频链路?

常见优化对比:

优化方式 模型变小 ONNX 推理可能变快 端到端一定变快 精度风险
Conv + BN 融合 少量 通常会 不一定 很低
ONNX 简化 可能 不一定 不一定 低
Static INT8 明显 可能 不一定 中等
FP16 GPU 明显 GPU 上可能 不一定 低到中等
TensorRT 不一定 通常明显 仍需测试 低到中等
线程数调整 否 可能 可能 无
光流跳帧 否 不改变单帧模型速度 通常会 跟踪风险
ROI 推理 否 局部推理更快 目标少时通常会 漏检风险

最终可以用下面这条链路检查自己的部署是否可靠:

1
2
3
4
5
6
7
8
9
10
11
12
13
输入契约一致
↓
Python 与 C++ 数值一致
↓
输出解析正确
↓
分段性能可观测
↓
图优化与线程策略可对比
↓
INT8 是否真正使用高效 kernel 可验证
↓
跨平台结果可复现

对于 YOLO + ONNX Runtime + OpenCV + ByteTrack + 光流这类项目,优化优先级建议是:

1
2
3
4
5
6
7
1. 输入输出契约与数值一致性
2. FP32 基线与分段性能统计
3. ORT 图优化与线程数 benchmark
4. Static INT8 量化与精度回归
5. 查看 INT8 算子、Q/DQ 与 CPU fallback
6. Android XNNPACK / NNAPI / QNN 等平台优化
7. ROI、光流、动态关键帧等系统级加速

12. 参考资料与延伸阅读

下面这些资料适合作为后续学习和博客补充参考。官方文档用于确认事实,社区文章用于参考工程写法和常见坑位。由于 YOLO 和 ONNX Runtime 更新很快,具体参数和默认行为要以自己安装版本的文档、yolo export --help、Netron 图结构和实际输出 shape 为准。

官方文档

实践文章与代码参考