简介:面向需要将YOLOv8模型落地为TensorRT C++推理管线的开发者,这份资源以X射线探伤检测为实际场景,提供完整Visual Studio解决方案,覆盖ONNX模型转换、TensorRT Engine构建、动态形状推理、显存管理及FP16/INT8精度调优等关键环节。资源包共85个文件、约379.2MB,以cpp/h源码、vcxproj/sln工程配置为主,附属pdb/tlog/obj等构建调试产物和jpg/png测试图像,结构与真实工业项目一致,便于用VS打开直接观察工程组织与运行链路。已有3362人学习,参考zy_Xray_inspection工程,内含main_tensorRT与segmentationModel两个模块以及logging.h、utils.h等工具类,可对照学习从PyTorch权重到TensorRT引擎的完整转换流程,并迁移到工业质检、安防监控等实时目标检测场景。
1. 用 TensorRT 跑 YOLOv8 的 C++ 部署:一份能直接编译的 X 射线检测工程
很多人用 Python 跑 YOLOv8 检测非常顺手,一转到 C++ 和 TensorRT 就卡住:模型导出来不知道交给谁、Engine 构建报错、推理结果全 0。这套 zy_Xray_inspection 工程就是一份能直接落地的答案——它把 YOLOv8 的 TensorRT C++ 部署链路完整走了一遍,而且场景是 X 射线检测,输入的是安检、工业质检里灰度细节丰富的图像,对预处理和后处理的要求比普通自然图像更高。工程基于 Visual Studio 组织,包含可执行工程 main_tensorRT 和封装成动态库的 segmentationModel,模型文件、测试图像与输出结果都在资源包里,拉下来编译就能对着断点看完整调用链。适合两类人:一是想从 Python 演示转到 C++ 部署的算法工程师,二是要在 x64 Windows 工业机上做实时检测的部署工程师。
2. 部署链路与工程结构:先摸清 .pt 到 .engine 的转换路径
TensorRT 不直接吃 .pt 权重,整个部署链路是 PyTorch 权重 → ONNX → TensorRT Engine → C++ 推理。很多新手卡在第一步,是因为不知道每一步谁负责、产物长什么样。这章我把这条链路按顺序拆开,顺便把工程里每个文件对应到链路的哪一环说明白。
2.1 ONNX 导出:动态维度与输出布局在这里定死
YOLOv8 训练得到的是 PyTorch 的 .pt 文件,TensorRT 不认识。常见做法是先用 Ultralytics 官方命令导出 ONNX,这一步直接决定了后面 C++ 代码里能拿到的输出张量结构。
yolo export model=yolov8s.pt format=onnx opset=12 dynamic=True simplify=True导出命令里 dynamic=True 是关键:它让导出的 ONNX 在 batch 维度和输入尺寸维度上保持动态,后面构建 TensorRT Engine 时才有机会设置 min/opt/max 三段形状。simplify 是调用 onnx-simplifier 把计算图里的冗余节点删掉,能减小模型体积,也能降低 TensorRT 解析时报错概率。opset 建议不低于 12,TensorRT 对低版本 opset 的算子覆盖不全,导出时图省事用默认值,后面 trtexec 可能直接报 Unsupported Operator。
导出之后第一件事,是用 Python 侧的 onnxruntime 或 netron 打开模型,确认输出层名字和维度。YOLOv8 的输出层是三个尺度特征图经过 concat 之后的 (1, 4+80, 8400),以 COCO 80 类为例,4 是框的 cx、cy、w、h,80 是类别分,8400 是三个 stride(8/16/32)下候选框总数,即 80×80 + 40×40 + 20×20。如果 X 射线检测重新训练成单类,输出就变成 (1, 5, 8400),这个数字会直接影响第 4 章的后处理代码,写死 84 的话整个检测结果全是乱的。
2.2 用 trtexec 先构建一版 Engine,作为 C++ 联调底稿
C++ 代码里可以通过 Builder 类现场构建 Engine,但我一般先把 TensorRT 自带的 trtexec 工具跑一遍。理由很简单:trtexec 能把「模型本身的问题」和「C++ 代码的问题」分开——trtexec 都报错,说明 ONNX 导出有问题,先回头查导出;trtexec 能出 Engine,后面 C++ 加载出问题,才需要怀疑代码。
trtexec --onnx=yolov8s.onnx --saveEngine=yolov8s.engine \ --minShapes=images:1x3x640x640 \ --optShapes=images:1x3x640x640 \ --maxShapes=images:8x3x640x640 \ --fp16 --workspace=4096minShapes/optShapes/maxShapes 对应动态 batch 的区间:min 是 1,opt 是实际业务最常出现的 batch(这里写 1),max 是显存能扛住的上限 8。optShapes 很关键,TensorRT 的 kernel 选择是按 opt 形状优化的,如果业务跑 batch=4 却把 opt 写成 1,性能会差一截。--fp16 开启半精度,--workspace=4096 表示允许 TensorRT 在构建阶段用最多 4GB 显存去尝试更多 kernel 组合。注意 workspace 是构建期参数,不是运行期参数,改它不会影响推理时显存占用。
2.3 工程文件布局:main_tensorRT 与 segmentationModel 各管一段
拿到资源包先别急着编译,把文件职责理清楚,后面调错能少走很多弯路。这套工程是 Visual Studio 解决方案,核心文件如下:
| 文件 / 目录 | 职责 |
|---|---|
| zy_Xray_inspection.sln | VS 解决方案,组织 main_tensorRT 与 segmentationModel 两个工程 |
| main_tensorRT.cpp | 可执行程序主入口:读图、调用推理、画框、保存 result.png |
| segmentationModel.h / .cpp | 把 Engine 创建、context 管理、后处理封装成推理类 |
| utils.h / logging.h / common.hpp | TensorRT 官方 sample 风格的工具代码,logger 和内存管理工具 |
| pch.h / framework.h / dllmain.cpp | segmentationModel 编译为 DLL 的工程支撑 |
| models / imgs | 模型文件目录;zidane.jpg、bus.jpg 测试图像 |
| result.png | 默认输出的检测结果图 |
main_tensorRT 是 exe,segmentationModel 是 DLL,这种拆分在工业项目里很常见:主程序负责 IO 和业务调度,推理模块独立成库,换模型、换硬件只改推理库不动上层。整套代码明显是照着 TensorRT 官方 samples 的套路改的,logging.h、common.hpp 这些文件名眼熟很正常,它们不是业务代码,别删。
主程序调用链很短,核心骨架是这样的:
#include "segmentationModel.h" #include "utils.h" int main() { // 1. 初始化推理引擎:加载 engine 文件或重新构建 SegmentationModel model; model.init("models/yolov8s.engine"); // 2. 读图 + 预处理,得到 NCHW 布局的 float 输入 cv::Mat img = cv::imread("imgs/bus.jpg"); std::vector<float> input = preprocess(img, 640); // 3. 推理:输入进显存,enqueue,输出取回 host model.infer(input.data()); // 4. 后处理:解析输出、NMS、坐标还原、画框 std::vector<Detection> dets = model.postprocess(0.25f, 0.45f); drawAndSave(img, dets, "result.png"); }这里 model.init() 负责反序列化 Engine,preprocess 返回的 float 数组后续会整块拷进显存,postprocess 的两个阈值 0.25 和 0.45 是 YOLO 系默认的置信度阈值和 NMS IoU 阈值。这段骨架对应到文件里就是 main_tensorRT.cpp 的 main 函数,单步跟一遍就能看清整个部署链路的顺序。
3. C++ 加载 Engine 执行推理:从反序列化到拿到检测结果
Engine 文件有了,C++ 侧要做的事就三件:把它反序列化成可执行对象、把输入送进显存、把输出拿回来。这章按顺序写,代码对应 segmentationModel.cpp 里 init 和 infer 两个方法的核心段落。
3.1 反序列化 Engine 与创建 ExecutionContext
反序列化就是把 .engine 文件读进内存,交给 TensorRT 的 Runtime 解析。这一过程不编译、不优化,所以非常快,通常几十毫秒。
std::ifstream file("yolov8s.engine", std::ios::binary); std::vector<char> blob((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>()); file.close(); nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(gLogger); nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(blob.data(), blob.size()); nvinfer1::IExecutionContext* context = engine->createExecutionContext();gLogger 来自 logging.h,是 TensorRT 官方 sample 的日志回调,负责把引擎的 warning/error 打到控制台。deserializeCudaEngine 只做加载,失败时返回 nullptr,排查方向很明确:Engine 文件损坏,或者 TensorRT 版本、GPU 架构不匹配,这个坑在第 5 章展开。
ExecutionContext 才是真正执行推理的对象,一个 Engine 可以创建多个 Context 来支持多线程并发推理。runtime、engine、context 三者生命周期要跨整个程序,程序退出时才 destroy,绝不能放在每次推理的函数里创建和释放。常见反例是每帧调用 createExecutionContext,显存和 CPU 句柄一路涨,跑几百帧就崩。
3.2 输入输出 Buffer 绑定:先设形状,再分显存
动态形状的 Engine 在执行前必须调用 setInputShape 指定本次输入的具体尺寸,TensorRT 才会给内部 tensor 分配合理的显存布局。
// 绑定名必须与 ONNX 导出时的输入名一致,一般是 "images" context->setInputShape("images", nvinfer1::Dims4{1, 3, 640, 640}); // 查询实际输出维度,YOLOv8 通常是 (1, 84, 8400) nvinfer1::Dims outDims = context->getTensorShape("output0"); size_t outSize = 1; for (int i = 0; i < outDims.nbDims; ++i) { outSize *= outDims.d[i]; } // 分配 device 侧显存 void* inputBuf, *outputBuf; cudaMalloc(&inputBuf, 1 * 3 * 640 * 640 * sizeof(float)); cudaMalloc(&outputBuf, outSize * sizeof(float)); // 老版本 TRT 8/9:bindings 数组顺序要和 engine 的 binding 顺序一致 int inputIdx = engine->getBindingIndex("images"); int outputIdx = engine->getBindingIndex("output0"); std::vector<void*> bindings(engine->getNbBindings(), nullptr); bindings[inputIdx] = inputBuf; bindings[outputIdx] = outputBuf;很多人图省事直接写 bindings = {inputBuf, outputBuf},赌输入是第 0 个 binding。多数官方导出的模型确实如此,但一旦网络里加了额外输入 output,顺序就变了,而且错位时不会报错,只是结果全乱。用 getBindingIndex 按名字取下标是最稳的写法。
TensorRT 10 之后 API 改成了 IExecutionContext::setTensorAddress,不再传 bindings 数组,而是给每个 tensor 单独指定 device 地址。两套写法编译不兼容,报错信息指向 enqueueV2 不存在时,先确认 TRT 是 8/9 还是 10,再选对应 API。
3.3 拷贝输入到显存并执行推理
输入预处理得到的 float 数组在 host 内存,要先用 cudaMemcpy 送进显存,然后 enqueue 到 stream 上执行。
cudaStream_t stream; cudaStreamCreate(&stream); // H2D:host 输入拷贝到 device cudaMemcpy(inputBuf, input.data(), 1 * 3 * 640 * 640 * sizeof(float), cudaMemcpyHostToDevice); // 异步执行推理 context->enqueueV2(bindings.data(), stream, nullptr); // D2H:输出拷回 host std::vector<float> hostOut(outSize); cudaMemcpy(hostOut.data(), outputBuf, outSize * sizeof(float), cudaMemcpyDeviceToHost); // 必须同步,否则 hostOut 里还没拿到数据 cudaStreamSynchronize(stream);enqueueV2 是异步的,调用后 GPU 在跑,CPU 不等待。如果同步前就去读 hostOut,拿到的全是旧数据或全 0。cudaStreamSynchronize 是保证「推理真正执行完」的边界。连续处理多帧时,inputBuf、outputBuf、hostOut、stream 全部复用,每帧只需更新 input.data() 的内容和 setInputShape。engine 和 context 只初始化一次,这是 C++ 部署性能的基本盘。
4. 预处理与后处理:Letterbox、8400 个候选框与 NMS
C++ 部署真正让人翻车的地方不在 TensorRT API,而在预处理和后处理。同一个 Engine,Python 侧测得好好的,换到 C++ 里改一下预处理,结果全乱。原因是 YOLOv8 的检测性能和预处理、后处理的每个数字强相关,差一点就掉点。
4.1 预处理必须用 Letterbox,不能直接 Resize
直接 cv::resize 到 640×640 会让非正方形的原图被拉伸,目标变形,检测精度掉得肉眼可见。YOLOv8 训练时的预处理是 letterbox:等比例缩放,剩余区域填充灰边,推理必须保持一致。
cv::Mat letterbox(const cv::Mat& src, int targetSize, cv::Mat& dst, float& scale, int& padW, int& padH) { int h = src.rows, w = src.cols; scale = std::min((float)targetSize / h, (float)targetSize / w); int newW = std::round(w * scale); int newH = std::round(h * scale); cv::resize(src, dst, cv::Size(newW, newH)); padW = targetSize - newW; padH = targetSize - newH; // 灰边均分上下/左右,值用 114,与训练保持一致 cv::copyMakeBorder(dst, dst, padH / 2, padH - padH / 2, padW / 2, padW - padW / 2, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); }关键参数是 scale 和 pad,后处理还原坐标时要用。灰边值 114 是 Ultralytics 训练代码里的固定值,改成 0 或其他值会导致分布偏移,精度受损。填充时 padH/2 和 padH - padH/2 是为了奇数像素也能均分,左右同理。BGR 转 RGB、除以 255 归一化、HWC 转 NCHW 通常在这步之后合成到 float 数组时一起做。
4.2 输出解析:84 × 8400 的内存排列
YOLOv8 的 ONNX 输出是 (1, 84, 8400),但这个 8400 在内存里不是按候选框排的,而是按通道排的:每个通道连续存 8400 个值。所以索引是(4 + c) * numBox + i,而不是i * 84 + (4 + c)。
const int numClass = 80; const int numBox = 8400; std::vector<float> scores; std::vector<cv::Rect> rawBoxes; std::vector<int> classIds; for (int i = 0; i < numBox; ++i) { // 前 4 个通道是 cx, cy, w, h,已经是对 640x640 输入的解码结果 float cx = hostOut[0 * numBox + i]; float cy = hostOut[1 * numBox + i]; float w = hostOut[2 * numBox + i]; float h = hostOut[3 * numBox + i]; // 后面的通道是类别概率,找最大分和对应类别 float maxScore = 0.0f; int maxId = -1; for (int c = 0; c < numClass; ++c) { float s = hostOut[(4 + c) * numBox + i]; if (s > maxScore) { maxScore = s; maxId = c; } } if (maxScore < 0.25f) continue; float x1 = cx - w / 2; float y1 = cy - h / 2; rawBoxes.emplace_back(x1, y1, w, h); scores.push_back(maxScore); classIds.push_back(maxId); }YOLOv8 的输出框已经解码过,不需要 anchor 或 stride 换算,这是 v8 相比 v5 更省事的地方。类别分有些导出流程会带上 sigmoid,有些是裸 logits,保险做法是在 Python 侧先打印一次输出范围:如果最大值超过 1,说明后处理要自己加 sigmoid。另外 numClass 建议从 Engine 输出维度反推:输出通道数减 4,而不是写死 80。自定义数据集训出单类模型时,这个数字错一位,整个后处理全部错位。
4.3 坐标还原、NMS 与画框
候选框的坐标是相对 640×640 输入图的,要映射回原始图像尺寸,必须减去 letterbox 的 padding,再除以缩放系数。注意 pad 要按上下均分的那份算,clamp 到图像边界内。
std::vector<cv::Rect> boxes; std::vector<float> finalScores; for (size_t i = 0; i < rawBoxes.size(); ++i) { float x1 = (rawBoxes[i].x - padW / 2.0f) / scale; float y1 = (rawBoxes[i].y - padH / 2.0f) / scale; float x2 = (rawBoxes[i].x + rawBoxes[i].width - padW / 2.0f) / scale; float y2 = (rawBoxes[i].y + rawBoxes[i].height - padH / 2.0f) / scale; x1 = std::max(0.0f, x1); y1 = std::max(0.0f, y1); x2 = std::min((float)img.cols, x2); y2 = std::min((float)img.rows, y2); boxes.emplace_back(x1, y1, x2 - x1, y2 - y1); finalScores.push_back(scores[i]); } std::vector<int> picked; cv::dnn::NMSBoxes(boxes, finalScores, 0.25f, 0.45f, picked); for (int idx : picked) { cv::rectangle(img, boxes[idx], cv::Scalar(0, 0, 255), 2); } cv::imwrite("result.png", img);cv::dnn::NMSBoxes 是 OpenCV 内置 NMS,参数顺序是 boxes、scores、confThreshold、iouThreshold、输出下标。iouThreshold 用 0.4~0.5 比较稳妥,X 射线检查里目标密集时,可以适当降到 0.3 减少漏检重叠目标。画框颜色按类别区分更实用,工程里 result.png 就是这条链路的最终产物,验证部署效果先看这张图,框的位置对不对一目了然。
5. TensorRT 部署避坑指南:版本、维度与显存三座大山
这套工程我前后跑过三轮才把流程完全走通,下面几条是实打实踩过的坑,现象、原因和解决办法都列出来,照着排查能省一两天时间。
5.1 反序列化直接报错:Engine 与 TensorRT 版本、GPU 强绑定
现象:deserializeCudaEngine 返回 nullptr,或者构建成功的 Engine 换一台机器加载就崩,没有任何先兆。
原因:Engine 文件是二进制,内部是 CUDA 内核的机器码,跟 TensorRT 版本和 GPU 架构强绑定。TRT 8 生成的 Engine 用 TRT 10 加载大概率失败,RTX 30 系上构建的 Engine 换到 RTX 20 系也可能崩。Engine 不是普通权重文件,不能随便拷贝移植。
解决:部署机和开发机保持同一 TensorRT 版本,换 GPU 环境就重新用 trtexec 生成。建议在项目里记录构建时用的 TensorRT 版本和显卡型号,或者在文件名里带上版本号,比如 yolov8s_trt8.6_rtx3060.engine,避免团队协作时其他人拿错文件。
5.2 推理结果全是 0 或框乱飞:预处理与输出布局没对上
现象:输出数组全 0,或者框在图上乱飞、置信度异常高、画出来的框和物体毫无关系。
原因:三类最常见。一是没用 letterbox 直接 resize 导致图像变形;二是 BGR 和 RGB 顺序颠倒;三是把 (1, 84, 8400) 当成 (1, 8400, 84) 解析,坐标和类别分全部错位。第三类最隐蔽,因为代码不报错,只是结果全错。
解决:先在 Python 端用 onnxruntime 加载同一个 ONNX 跑一张图,拿到标准输出;再对比 C++ 侧解析出的分数,逐行核对索引公式。我一般先查预处理(letterbox 参数),再查输出排列,最后查 sigmoid 是否重复。三者都对了,结果基本就对了。
5.3 显存持续增长或跑几十帧卡死
现象:任务管理器里显存一路涨,或者推理一段时间后突然报 cudaErrorMemoryAllocation,程序直接退出。
原因:最常见的是每帧都在 cudaMalloc 分配新显存,或者每帧都 createExecutionContext 不释放。动态形状 Engine 还有一个隐蔽问题:没有每次 setInputShape 就 enqueue,TensorRT 内部为了安全会重新分配内存,累积下来就是泄漏。host 和 device 指针搞混、同一个 buffer 反复释放也会触发这类崩溃。
解决:engine、context、所有显存 buffer 在初始化阶段一次性分配,推理循环里只做 cudaMemcpy 和 enqueueV2。多分辨率输入时,只在分辨率变化时重新 setInputShape。可以用 CUDA 的 cudaMemGetInfo 在每 1000 帧打一次显存占用,确认是否还有增长。
5.4 X 光图像开 FP16 后精度掉得厉害
现象:FP32 下检出率正常,开 FP16 后 X 光图中对比度低的违禁品、暗部缺陷开始漏检。
原因:FP16 只有 10 位尾数精度,YOLOv8 在灰度细节丰富、小目标多的 X 光图像上,对低置信度框的分数很敏感,精度折损直接体现在漏检上。这不是玄学,是这个部署场景的数值精度不足以支撑 FP16。
解决:先 FP32 全流程跑通做基线,记录同一批测试图的 mAP 或检出率;再开 FP16 对比。掉点在 1% 以内可接受,超过 2% 就退回 FP32,或者考虑 INT8 PTQ。但 INT8 量化必须用有代表性的校准集,拿 20 张图凑合校准,精度会比 FP16 崩得更惨。X 射线这类灰度图,校准集要覆盖不同密度的被检物。
5.5 C++ 里类别数写死导致后处理错位
现象:用自训练的单类 X 光模型部署,后处理一个框都画不对,或画的全是没意义的框。
原因:把 84 这种 COCO 数字写死在代码宏里。模型输出是 (1, 5, 8400),代码按 4+80 去解析,坐标和类别分全错位。C++ 是强类型语言,这种错位编译期完全发现不了。
解决:后处理的 numClass 从 Engine 输出维度反推——输出通道数减 4 就是类别数。初始化时读一次 outDims.d[1],存成成员变量,后续所有循环都用它。这样换模型不用改代码。
6. 性能验证与进阶调优:先测准延迟,再动精度模式
部署最后一步是证明它真的快。很多人用 CPU 计时测 GPU 推理,测出来的数字忽高忽低不稳定,原因在于 enqueue 是异步的。正确做法是用 CUDA Event 在 stream 上打时间戳。
6.1 用 CUDA Event 测真实推理耗时
cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); // 预热 10 帧,排除初始化带来的假慢 for (int i = 0; i < 10; ++i) { context->enqueueV2(bindings.data(), stream, nullptr); cudaStreamSynchronize(stream); } cudaEventRecord(start, stream); context->enqueueV2(bindings.data(), stream, nullptr); cudaEventRecord(stop, stream); cudaStreamSynchronize(stream); float ms = 0.0f; cudaEventElapsedTime(&ms, start, stop); printf("infer time: %.2f ms\n", ms);CUDA Event 直接记录 GPU 端的执行起止,不受 CPU 调度抖动影响。取 30 帧算平均,比单次测量稳定。这套方法测出来的延迟,才是对外承诺性能指标的依据。要排除预处理耗时就把 letterbox 放到计时外;要测端到端延迟就放在计时间内,两者对应不同业务口径。
6.2 三条可落地的调优策略
第一是 workspace。构建 Engine 时给足 workspace,TensorRT 构建器才能尝试更多 kernel 实现并选出最优组合。老版本 API 叫 setMaxWorkspaceSize,TRT 10 后改成 setMemoryPoolLimit,编译报错就换对应接口。
第二是精度模式。FP16 在多数 GPU 上能拿到 1.5 到 2 倍提速,但第 5 章说过,X 光场景必须先跑 FP32 基线再对比,不能默认开。INT8 的提升更大,但要校准集和炼丹式的调参,项目周期紧就先用 FP16。
第三是动态 batch。把 optShapes 设成业务真实 batch,比如 4,让 TensorRT 按 batch=4 优化 kernel;maxShapes 不要贪大,显存占用按 maxShapes 计算,设 16 意味着要为 16 路并发预留显存。
我自己每次拿到新工程,第一件事永远是先用 trtexec 在当前机器上重新生成 Engine,把 TensorRT 版本号和 GPU 型号记进文件名,然后跑 FP32 基线、FP16 对比、动态 batch 压力测试,最后才动 C++ 业务代码。这套 zy_Xray_inspection 工程建议也按这个顺序拆:先编译跑通,再单步看 main_tensorRT.cpp 的调用链,最后换自己的模型和阈值。这套流程走顺后,以后换任何 YOLO 系列模型都能半小时内落地。希望帮到你。
本文还有配套的精品资源,点击获取