YOLO11 模型 C++ 部署:ONNX 导出、NMS 判别与推理实战
1. 现象与本质
现象:C++ 推理出现'满屏框'或'0 框'。
根因:导出的 ONNX 多为不带 NMS 的原始头,输出形状常见 [1, 84, 8400](84=4+80),模型不会帮你做阈值与 NMS;C++ 若不做后处理,就会'满屏框'。

相对地,带 NMS 的 ONNX(end2end) 会直接输出最终框,形状常见 [N,6] 或 [1,N,6](x1 y1 x2 y2 score class),C++ 只需画框。
2. NMS 是什么?为什么一定要做?
YOLO 在多尺度(如 80×80、40×40、20×20)上为每个网格点预测很多候选框。同一目标附近会出现大量位置相近、得分也不低的候选。NMS(Non-Maximum Suppression,非极大值抑制)的任务就是:在重叠的候选中,只保留那个最靠谱的(分数最高),把与之高度重叠的重复框删掉。
2.1 NMS 是怎么做的(硬 NMS 伪代码)
- IoU(交并比):IoU(A,B) = Area(A∩B) / Area(A∪B) ∈ [0, 1]
- 硬 NMS 两步:
- 按置信度降序排序;
- 依次取最高分框,删除与它 IoU 大于阈值(如 0.45)的后续框。
# boxes: [N, 4] in x1y1x2y2
# scores: [N]
# thr_conf: 置信度阈值(如 0.25)
# thr_iou: NMS IoU 阈值(如 0.45)
keep = []
idxs = argsort(scores, descending=True)
while idxs not empty:
i = idxs[0]
keep.append(i)
ious = IoU(boxes[i], boxes[idxs[1:]])
idxs = idxs[1:][ious <= thr_iou]
return keep
常见做法是按类别分别做 NMS(class-wise NMS)。Ultralytics 默认如此。
2.2 一个极小数值例子
| idx | score | box(x1,y1,x2,y2) |
|---|---|---|
| 0 | 0.90 | (10,10,50,50) |
| 1 | 0.85 | (12,12,49,49) |
| 2 | 0.60 | (200,200,260,260) |
| 3 | 0.40 | (205,205,258,258) |
- 0 与 1 重叠很高 → 留 0,抑制 1
- 2 与 3 接近 → 留 2,抑制 3 最终仅保留 idx=0、idx=2 两个框
3. 最佳做法:导出'带 NMS'的 ONNX
必须从
.pt权重导出;不能在现有.onnx上'打开 NMS'。
from ultralytics import YOLO
m = YOLO(r"path/to/best.pt") # 训练得到的 .pt 或 yolov8n.pt
m.export(format="onnx", imgsz=640, # 与训练一致 dynamic=False, simplify=True, # 安装了 onnxsim 就 True,没有也行 nms=True, # ★★★ 关键:将 NonMaxSuppression 写进图里 conf=0.25, # 内置置信阈 iou=0.45, # 内置 NMS IoU opset=12 # 12/13/16 均可,保持一致即可)
导出成功后,继续下一节检查是否真的带 NMS。
4. 如何判断是否'带 NMS'
4.1 Python(最快)
import onnxruntime as ort
sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"])
print([(o.name, o.shape) for o in sess.get_outputs()]) # 看到 [..., 6] / [..., 7] => 带 NMS
# 看到 84/85 出现在任一维 => 原始头(不带 NMS)

4.2 C++(不依赖 Python)
#include <onnxruntime_cxx_api.h>
#include <iostream>
#include <vector>
#include <filesystem>
static std::string Shape2Str(const std::vector<int64_t>& v){
std::string s = "[";
for(size_t i = 0; i < v.size(); ++i){
s += std::to_string(v[i]);
if(i + 1 < v.size()) s += ',';
}
return s += ']';
}
static bool LikeNMS(const std::vector<int64_t>& shp){
return shp.size() >= 2 && (shp.back() == 6 || shp.back() == 7);
}
static bool LikeRaw(const std::vector<int64_t>& shp){
for(auto d : shp)
if(d == 84 || d == 85) return true;
return false;
}
static void InspectOutputs(const Ort::Session& sess){
const size_t n = sess.GetOutputCount();
bool bAnyN = false, bAnyR = false;
for(size_t i = 0; i < n; ++i){
Ort::AllocatorWithDefaultOptions alloc;
auto name = sess.GetOutputNameAllocated(i, alloc);
auto shp = sess.GetOutputTypeInfo(i).GetTensorTypeAndShapeInfo().GetShape();
std::cout << "out[" << i << "] " << (name ? name.get() : "(null)") << " shape=" << Shape2Str(shp) << "\n";
bAnyN |= LikeNMS(shp);
bAnyR |= LikeRaw(shp);
}
if(bAnyN) std::cout << "[detect] ✅ 带 NMS 的 end2end 输出\n";
else if(bAnyR) std::cout << "[detect] ⚠️ 原始头(不带 NMS)\n";
else std::cout << "[detect] ℹ️ 非常见形状,建议用 Netron 检查是否含 NonMaxSuppression\n";
}
int main(int argc, char** argv){
if(argc < 2){
std::cout << "Usage:\n " << argv[0] << " <onnx_model>\n";
return 0;
}
std::filesystem::path strOnnxPath = argv[1];
if(!std::filesystem::exists(strOnnxPath)){
std::cerr << "[error] model not found: " << strOnnxPath;
return 1;
}
// ... truncated
}
5. 输出张量的物理含义
(此处省略后续内容,基于原文逻辑推断)
6. 最小 C++ 示例(带 NMS 的 ONNX)
(此处省略后续内容)
7. 如果手上只有'原始头'ONNX(84/85)怎么办?
(此处省略后续内容)
8. vcpkg 无 ORT Config.cmake:CMake 兜底
(此处省略后续内容)
9. 常见坑位与速查
(此处省略后续内容)
10. Checklist
(此处省略后续内容)
附录:NMS 变体与阈值选择
(此处省略后续内容)


