
简介这份资源面向需要在边缘设备或算力受限环境中落地目标检测的开发者提供使用ONNXRuntime部署轻量级YOLOv5-lite模型的完整示例。针对OpenCV DNN模块读取ONNX文件出错的问题作者改用ONNXRuntime作为推理引擎并同时给出C与Python两套实现便于不同技术栈的读者对照移植。压缩包共14个文件、约41.35MB包含cpp与py源码、多个onnx模型文件、coco.names类别标签、jpg与png示例图片以及md说明文档覆盖从模型加载、图像预处理到检测结果输出的关键环节。读者可借此理解ONNXRuntime的跨平台推理流程掌握YOLOv5-lite在C与Python下的调用方式并参考示例图片快速验证部署效果。目前已有71人学习适合具备一定深度学习与编程基础、希望将检测模型部署到实际项目中的开发者参考。1. 从 PyTorch 到 ONNXRuntimeyolov5-lite 部署到底在解决什么问题你训练完一个 yolov5-lite 模型拿到一个.pt文件在 PyTorch 环境里跑得挺欢。但到了真正要交付的环节——比如给一个 C 写的桌面软件加检测功能或者塞进一个没有完整 Python 环境的边缘设备——问题就来了总不能要求目标机器上装 PyTorch、装 CUDA、配一堆依赖吧。ONNXRuntime 部署 yolov5-lite 目标检测解决的就是这个「训练归训练、落地归落地」的断层。它把模型转成.onnx中间格式再用一个轻量推理引擎加载Python 和 C 都能调CPU 上也能跑出可用的帧率。这套方案适合两类人一是手里已经有 yolov5-lite 权重、想把它集成进 C 工程的工程师二是想用 Python 快速验证 ONNXRuntime 推理效果、再决定要不要上 C 的算法同学。下面我按「先跑通 Python、再啃 C、最后调优」的顺序把这条路走一遍。2. 导出 ONNX 与 Python 端推理先把最小闭环跑通2.1 为什么选 yolov5-lite 而不是原版 yolov5yolov5-lite 的核心改动在骨干网络和检测头。它用 ShuffleNetV2 或 RepVGG 这类轻量结构替换了原版 yolov5 的 CSPDarknet参数量和计算量都压下来一大截。原版 yolov5s 大概 7.2M 参数、16.5 GFLOPsyolov5-lite 的 s 版本能压到 1.7M 参数、3.5 GFLOPs 左右。这意味着在同样的 CPU 上ONNXRuntime 跑 yolov5-lite 的推理延迟可能只有原版的三分之一到一半。但轻量是有代价的。小目标检测精度会掉尤其是密集场景下。如果你的业务场景里目标普遍偏小比如无人机航拍或者监控远距离行人yolov5-lite 可能需要配合更高分辨率的输入比如 640 甚至 1280来补偿。我一般会先用 640 跑一版看 mAP如果掉得厉害再考虑换 n 版本或者调输入尺寸。选它的另一个理由是导出 ONNX 比较干净。原版 yolov5 的 Focus 层在导出时会有一些算子兼容问题yolov5-lite 的结构更规整ONNXRuntime 的算子集覆盖得比较好。2.2 导出 ONNX 模型的具体命令与参数假设你已经拿到了 yolov5-lite 的.pt权重文件比如yolov5-lite-s.pt。导出脚本通常在仓库的models/export.py或者根目录的export.py。我一般用命令行方式python export.py \ --weights yolov5-lite-s.pt \ --img-size 640 640 \ --batch-size 1 \ --dynamic \ --simplify \ --opset 12 \ --output yolov5-lite-s.onnx逐项说明--img-size要和训练时一致否则 anchor 匹配会出问题--dynamic让 batch 维度和 H/W 维度变成动态的方便后续换输入尺寸但有些 ONNXRuntime 版本对动态维度支持不完美如果只跑固定尺寸可以去掉--simplify会调用 onnx-simplifier 做图优化去掉冗余算子强烈建议加上--opset 12是我实测比较稳的版本opset 11 有时会遇到 Resize 算子的问题opset 13 在某些 ONNXRuntime 版本上又有兼容性坑。导出完成后用onnxruntime的 Python API 验证一下模型能不能加载import onnxruntime as ort import numpy as np # 加载模型指定 CPU 执行提供者 session ort.InferenceSession( yolov5-lite-s.onnx, providers[CPUExecutionProvider] ) # 打印输入输出信息确认维度 for inp in session.get_inputs(): print(f输入名: {inp.name}, 形状: {inp.shape}, 类型: {inp.type}) for out in session.get_outputs(): print(f输出名: {out.name}, 形状: {out.shape}, 类型: {out.type}) # 构造一个假输入跑一次 dummy np.random.randn(1, 3, 640, 640).astype(np.float32) outputs session.run(None, {session.get_inputs()[0].name: dummy}) print(f输出数量: {len(outputs)}) for i, o in enumerate(outputs): print(f输出 {i} 形状: {o.shape})这段代码的逻辑是先创建推理会话然后打印输入输出的元信息确认模型结构符合预期。yolov5-lite 导出后通常有 3 个输出对应 stride 8、16、32 三个检测头每个输出的形状是[1, na, h, w, no]其中na是 anchor 数量no是每个 anchor 的输出维度4 个框坐标 1 个 objectness 类别数。如果输出数量不对说明导出时检测头没被正确识别。2.3 Python 端完整推理与后处理拿到 ONNXRuntime 的输出后还需要做解码和 NMS。下面是一个完整的 Python 推理脚本import cv2 import numpy as np import onnxruntime as ort class YOLOv5LiteONNX: def __init__(self, onnx_path, conf_thres0.25, iou_thres0.45, img_size640): self.session ort.InferenceSession( onnx_path, providers[CPUExecutionProvider] ) self.input_name self.session.get_inputs()[0].name self.conf_thres conf_thres self.iou_thres iou_thres self.img_size img_size # yolov5-lite 的 anchor按 stride 分组 self.anchors [ [[10, 13], [16, 30], [33, 23]], # stride 8 [[30, 61], [62, 45], [59, 119]], # stride 16 [[116, 90], [156, 198], [373, 326]] # stride 32 ] self.strides [8, 16, 32] def preprocess(self, img): # letterbox 缩放保持长宽比 h, w img.shape[:2] scale min(self.img_size / h, self.img_size / w) nh, nw int(h * scale), int(w * scale) resized cv2.resize(img, (nw, nh)) canvas np.full((self.img_size, self.img_size, 3), 114, dtypenp.uint8) top (self.img_size - nh) // 2 left (self.img_size - nw) // 2 canvas[top:topnh, left:leftnw] resized # BGR - RGB, HWC - CHW, 归一化 blob canvas[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 blob np.expand_dims(blob, axis0) return blob, scale, left, top def postprocess(self, outputs, scale, pad_left, pad_top, orig_shape): detections [] for i, out in enumerate(outputs): stride self.strides[i] anchors self.anchors[i] # out shape: [1, na, h, w, no] _, na, h, w, no out.shape out out.reshape(na, h, w, no) # 生成网格中心 grid_y, grid_x np.meshgrid(np.arange(h), np.arange(w), indexingij) grid_x grid_x[None, :, :] grid_y grid_y[None, :, :] # 解码 xywh xy (out[..., 0:2] * 2 - 0.5 np.stack([grid_x, grid_y], axis-1)) * stride wh (out[..., 2:4] * 2) ** 2 * np.array(anchors).reshape(na, 1, 1, 2) conf out[..., 4:5] cls out[..., 5:] scores conf * cls max_scores scores.max(axis-1) class_ids scores.argmax(axis-1) mask max_scores self.conf_thres if not mask.any(): continue xy xy[mask] wh wh[mask] max_scores max_scores[mask] class_ids class_ids[mask] # xywh - xyxy x1 xy[:, 0] - wh[:, 0] / 2 y1 xy[:, 1] - wh[:, 1] / 2 x2 xy[:, 0] wh[:, 0] / 2 y2 xy[:, 1] wh[:, 1] / 2 boxes np.stack([x1, y1, x2, y2], axis-1) detections.append(np.concatenate([boxes, max_scores[:, None], class_ids[:, None]], axis-1)) if not detections: return np.zeros((0, 6)) dets np.concatenate(detections, axis0) # NMS keep self.nms(dets, self.iou_thres) dets dets[keep] # 映射回原图坐标 dets[:, [0, 2]] (dets[:, [0, 2]] - pad_left) / scale dets[:, [1, 3]] (dets[:, [1, 3]] - pad_top) / scale return dets def nms(self, dets, iou_thres): x1, y1, x2, y2, scores, classes dets.T areas (x2 - x1) * (y2 - y1) order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) xx1 np.maximum(x1[i], x1[order[1:]]) yy1 np.maximum(y1[i], y1[order[1:]]) xx2 np.minimum(x2[i], x2[order[1:]]) yy2 np.minimum(y2[i], y2[order[1:]]) w np.maximum(0.0, xx2 - xx1) h np.maximum(0.0, yy2 - yy1) inter w * h iou inter / (areas[i] areas[order[1:]] - inter 1e-6) inds np.where(iou iou_thres)[0] order order[inds 1] return keep def detect(self, img): blob, scale, left, top self.preprocess(img) outputs self.session.run(None, {self.input_name: blob}) return self.postprocess(outputs, scale, left, top, img.shape[:2]) # 使用示例 if __name__ __main__: model YOLOv5LiteONNX(yolov5-lite-s.onnx) img cv2.imread(test.jpg) dets model.detect(img) for d in dets: x1, y1, x2, y2, score, cls d cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(img, f{int(cls)}:{score:.2f}, (int(x1), int(y1)-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite(result.jpg, img)这段代码的关键点在于后处理的解码逻辑。yolov5-lite 的输出是相对网格的偏移量需要先加上网格坐标再乘以 stride 才能得到原图尺度下的中心点。wh 的解码是(预测值 * 2) ** 2 * anchor这个公式和原版 yolov5 一致。NMS 部分用的是纯 NumPy 实现没有依赖 torchvision这样整个 Python 端只需要onnxruntime、opencv-python和numpy三个包部署时非常干净。参数方面conf_thres默认 0.25 适合大多数场景如果误检多可以提到 0.4iou_thres默认 0.45密集场景可以降到 0.3 来抑制重叠框。img_size必须和导出时一致否则 anchor 匹配会错位。3. C 端集成用 ONNXRuntime 动态库跑通推理3.1 环境准备与 ONNXRuntime C 库的获取C 端的第一步是把 ONNXRuntime 的动态库拿到手。官方发布页有预编译包Windows 下是.zipLinux 下是.tgz。解压后目录结构大概是onnxruntime/ ├── include/ │ ├── onnxruntime_cxx_api.h │ ├── onnxruntime_c_api.h │ └── ... ├── lib/ │ ├── onnxruntime.lib (Windows) │ ├── onnxruntime.dll (Windows) │ ├── libonnxruntime.so (Linux) │ └── ...Windows 下用 Visual Studio 的话在项目属性里把include目录加到「附加包含目录」把lib目录加到「附加库目录」然后在「附加依赖项」里填onnxruntime.lib。运行时需要把onnxruntime.dll放到 exe 同目录或者系统 PATH 里。Linux 下编译时加-lonnxruntime运行时用LD_LIBRARY_PATH指向 so 所在目录。有个坑要注意ONNXRuntime 的 C API 对 C 标准有要求至少 C14建议用 C17。Visual Studio 2019 及以上版本没问题老版本 VS2017 可能需要手动开/std:c17。3.2 C 推理类的完整实现下面是一个封装好的 C 推理类包含预处理、推理和后处理#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include vector #include algorithm #include cmath struct Detection { float x1, y1, x2, y2; float score; int class_id; }; class YOLOv5LiteONNX { public: YOLOv5LiteONNX(const std::string model_path, float conf_thres 0.25f, float iou_thres 0.45f, int img_size 640) : conf_thres_(conf_thres), iou_thres_(iou_thres), img_size_(img_size), env_(ORT_LOGGING_LEVEL_WARNING, yolov5-lite) { Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); session_ std::make_uniqueOrt::Session(env_, model_path.c_str(), opts); // 获取输入输出名称 Ort::AllocatorWithDefaultOptions allocator; input_name_ session_-GetInputNameAllocated(0, allocator).get(); for (size_t i 0; i session_-GetOutputCount(); i) { output_names_.push_back( session_-GetOutputNameAllocated(i, allocator).get()); } } std::vectorDetection detect(const cv::Mat img) { // 预处理 cv::Mat blob; float scale, pad_left, pad_top; preprocess(img, blob, scale, pad_left, pad_top); // 构造输入 tensor std::vectorint64_t input_shape {1, 3, img_size_, img_size_}; size_t input_size 1 * 3 * img_size_ * img_size_; std::vectorfloat input_data(input_size); // blob 是 CV_32F连续内存 std::memcpy(input_data.data(), blob.ptrfloat(), input_size * sizeof(float)); auto memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_data.data(), input_size, input_shape.data(), input_shape.size()); // 推理 std::vectorconst char* input_names {input_name_.c_str()}; std::vectorconst char* output_names_c; for (auto n : output_names_) output_names_c.push_back(n.c_str()); auto outputs session_-Run( Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names_c.data(), output_names_c.size()); // 后处理 return postprocess(outputs, scale, pad_left, pad_top, img.size()); } private: void preprocess(const cv::Mat img, cv::Mat blob, float scale, float pad_left, float pad_top) { int h img.rows, w img.cols; scale std::min((float)img_size_ / h, (float)img_size_ / w); int nh (int)(h * scale), nw (int)(w * scale); cv::Mat resized; cv::resize(img, resized, cv::Size(nw, nh)); pad_top (img_size_ - nh) / 2.0f; pad_left (img_size_ - nw) / 2.0f; cv::Mat canvas(img_size_, img_size_, CV_8UC3, cv::Scalar(114, 114, 114)); resized.copyTo(canvas(cv::Rect((int)pad_left, (int)pad_top, nw, nh))); // BGR-RGB, 归一化, HWC-CHW cv::cvtColor(canvas, canvas, cv::COLOR_BGR2RGB); canvas.convertTo(canvas, CV_32FC3, 1.0 / 255.0); cv::dnn::blobFromImage(canvas, blob, 1.0, cv::Size(img_size_, img_size_), cv::Scalar(), false, false); } std::vectorDetection postprocess( const std::vectorOrt::Value outputs, float scale, float pad_left, float pad_top, cv::Size orig_size) { std::vectorDetection detections; // anchors 按 stride 分组 std::vectorstd::vectorstd::vectorfloat anchors { {{10, 13}, {16, 30}, {33, 23}}, {{30, 61}, {62, 45}, {59, 119}}, {{116, 90}, {156, 198}, {373, 326}} }; std::vectorint strides {8, 16, 32}; for (size_t i 0; i outputs.size(); i) { auto shape outputs[i].GetTensorTypeAndShapeInfo().GetShape(); // shape: [1, na, h, w, no] int na (int)shape[1], h (int)shape[2], w (int)shape[3]; int no (int)shape[4]; int stride strides[i]; const float* data outputs[i].GetTensorDatafloat(); for (int a 0; a na; a) { for (int gy 0; gy h; gy) { for (int gx 0; gx w; gx) { const float* ptr data ((a * h gy) * w gx) * no; float obj_conf ptr[4]; if (obj_conf conf_thres_) continue; // 找最大类别 int best_cls 0; float best_score 0; for (int c 5; c no; c) { if (ptr[c] best_score) { best_score ptr[c]; best_cls c - 5; } } float score obj_conf * best_score; if (score conf_thres_) continue; // 解码 float cx (ptr[0] * 2 - 0.5f gx) * stride; float cy (ptr[1] * 2 - 0.5f gy) * stride; float bw powf(ptr[2] * 2, 2) * anchors[i][a][0]; float bh powf(ptr[3] * 2, 2) * anchors[i][a][1]; Detection d; d.x1 (cx - bw / 2 - pad_left) / scale; d.y1 (cy - bh / 2 - pad_top) / scale; d.x2 (cx bw / 2 - pad_left) / scale; d.y2 (cy bh / 2 - pad_top) / scale; d.score score; d.class_id best_cls; detections.push_back(d); } } } } // NMS std::sort(detections.begin(), detections.end(), [](const Detection a, const Detection b) { return a.score b.score; }); std::vectorDetection result; std::vectorbool suppressed(detections.size(), false); for (size_t i 0; i detections.size(); i) { if (suppressed[i]) continue; result.push_back(detections[i]); for (size_t j i 1; j detections.size(); j) { if (suppressed[j]) continue; float iou computeIoU(detections[i], detections[j]); if (iou iou_thres_) suppressed[j] true; } } return result; } float computeIoU(const Detection a, const Detection b) { float xx1 std::max(a.x1, b.x1); float yy1 std::max(a.y1, b.y1); float xx2 std::min(a.x2, b.x2); float yy2 std::min(a.y2, b.y2); float w std::max(0.0f, xx2 - xx1); float h std::max(0.0f, yy2 - yy1); float inter w * h; float area_a (a.x2 - a.x1) * (a.y2 - a.y1); float area_b (b.x2 - b.x1) * (b.y2 - b.y1); return inter / (area_a area_b - inter 1e-6f); } Ort::Env env_; std::unique_ptrOrt::Session session_; std::string input_name_; std::vectorstd::string output_names_; float conf_thres_, iou_thres_; int img_size_; };这段代码的核心逻辑和 Python 版一致但有几个 C 特有的注意点。第一Ort::Session的构造需要传入Ort::Env这个 env 对象生命周期要覆盖 session所以我把 env 作为成员变量声明在 session 之前。第二输入 tensor 的内存需要保持有效直到Run返回这里用std::vectorfloat在栈上分配Run是同步的所以没问题。第三输出 tensor 的数据指针在Run返回后仍然有效但下一次Run会覆盖所以后处理要在下一次推理前完成。参数方面SetIntraOpNumThreads(4)控制算子内并行线程数CPU 核心多的话可以调到 8。GraphOptimizationLevel::ORT_ENABLE_ALL开启所有图优化包括常量折叠和算子融合对推理速度有提升。3.3 编译与链接的实操细节Windows Visual Studio 的编译命令在开发者命令行里cl /std:c17 /EHsc /O2 /I onnxruntime/include /I opencv/include ^ main.cpp /link /LIBPATH:onnxruntime/lib /LIBPATH:opencv/lib ^ onnxruntime.lib opencv_world455.libLinux CMake 的CMakeLists.txtcmake_minimum_required(VERSION 3.15) project(yolov5_lite_onnx) set(CMAKE_CXX_STANDARD 17) find_package(OpenCV REQUIRED) include_directories(${OpenCV_INCLUDE_DIRS} /path/to/onnxruntime/include) link_directories(/path/to/onnxruntime/lib) add_executable(detect main.cpp) target_link_libraries(detect ${OpenCV_LIBS} onnxruntime)编译时如果报undefined reference to Ort::...八成是链接顺序问题把onnxruntime放在 OpenCV 后面。Windows 下如果报找不到 onnxruntime.dll把 dll 复制到 exe 同目录即可。4. 避坑与排查部署路上最容易翻车的五个点4.1 导出 ONNX 后输出形状不对现象Python 端加载模型后输出数量不是 3 个或者某个输出的维度里no不等于5 类别数。原因导出时模型处于 train 模式检测头没有切换到 inference 模式或者导出脚本里的--include onnx参数没加导出了 TorchScript 而不是 ONNX。解决导出前确保调用model.eval()并且用官方推荐的export.py脚本。如果输出维度里no少了 1检查是不是 objectness 被合并到了类别分数里有些 yolov5-lite 变体会做这种简化后处理代码要相应调整。4.2 ONNXRuntime 加载模型时报维度不匹配现象Invalid Feed Input Name或者Got invalid dimensions for input。原因导出时用了--dynamic但推理时传入的输入形状和模型期望的不一致或者输入名称搞错了ONNXRuntime 对输入名称大小写敏感。解决用session.get_inputs()[0].name动态获取输入名不要硬编码。如果用了动态维度推理时传入的 H/W 必须是 32 的倍数否则某些算子会报错。4.3 C 端推理结果和 Python 端对不上现象同一张图Python 端检测框正常C 端框的位置偏移或者分数异常。原因预处理不一致。Python 端用 OpenCV 的cv2.resize默认是双线性插值C 端如果用了最近邻插值像素值会有差异。另一个常见原因是归一化方式不同Python 端除以 255C 端忘了除。解决把两边的预处理中间结果resize 后的图像、归一化后的 blob分别保存成文件对比逐像素检查。我一般会在预处理后把 blob 的前 10 个值打印出来两边对一下。4.4 CPU 推理速度远低于预期现象Python 端单张 640x640 推理要 200ms 以上C 端也没快多少。原因ONNXRuntime 默认可能用了单线程或者图优化没开。另一个原因是模型本身没简化ONNX 图里有大量冗余算子。解决Python 端在InferenceSession里加sess_options.intra_op_num_threads 8C 端用opts.SetIntraOpNumThreads(8)和opts.SetGraphOptimizationLevel(ORT_ENABLE_ALL)。导出时加--simplify用 onnx-simplifier 过一遍。如果还慢考虑用onnxruntime-gpu或者换 OpenVINO 执行提供者。4.5 内存泄漏与重复加载现象C 程序跑一段时间后内存持续增长或者反复创建 session 导致句柄耗尽。原因每次推理都新建Ort::Session没有复用。或者Ort::Value的输入 tensor 在Run之后没有释放。解决Ort::Session创建一次全局复用。输入 tensor 用Ort::Value::CreateTensor创建后Run返回时它会自动析构不需要手动释放。如果用了Ort::Allocator分配输出内存记得用对应的释放接口。5. 进阶用 ONNXRuntime 的 IO Binding 减少数据拷贝5.1 为什么 IO Binding 能提速默认的session.Run()每次调用都会把输入数据从用户内存拷贝到 ONNXRuntime 的内部缓冲区输出也是拷贝出来的。对于 640x640 的输入一次拷贝大概 4.9MB1×3×640×640×4 字节输出也有几 MB。在连续视频流场景下这个拷贝开销累积起来很可观。IO Binding 允许你直接把输入 tensor 绑定到 ONNXRuntime 预分配的内存上输出也直接写到指定缓冲区省掉中间的拷贝。实测在 CPU 上能有 10% 到 20% 的延迟降低GPU 上提升更明显。5.2 C 端 IO Binding 的实现// 在类里增加成员 Ort::IoBinding io_binding_{nullptr}; std::vectorfloat input_buffer_; std::vectorstd::vectorfloat output_buffers_; // 初始化时绑定 void initBinding() { io_binding_ Ort::IoBinding(*session_); // 输入绑定 std::vectorint64_t input_shape {1, 3, img_size_, img_size_}; size_t input_size 1 * 3 * img_size_ * img_size_; input_buffer_.resize(input_size); auto mem_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( mem_info, input_buffer_.data(), input_size, input_shape.data(), input_shape.size()); io_binding_.BindInput(input_name_.c_str(), input_tensor); // 输出绑定先跑一次拿到输出形状 // 这里省略实际使用时根据第一次 Run 的输出形状预分配 } // 推理时 std::vectorDetection detectWithBinding(const cv::Mat img) { // 预处理写入 input_buffer_ preprocessToBuffer(img); io_binding_.Run(); // 从 output_buffers_ 读取结果做后处理 // ... }IO Binding 的坑在于输出形状需要预先知道。如果模型是动态维度的第一次 Run 之前拿不到准确的输出形状需要先跑一次普通 Run 获取形状再初始化绑定。另外绑定后的内存由用户管理要确保在Run期间不被释放或重新分配。5.3 验证提速效果的方法我一般用std::chrono在 C 端做 100 次推理取平均Python 端用time.perf_counter。对比开不开 IO Binding 的耗时如果提升不到 5%说明瓶颈不在拷贝上可能是模型本身计算量太大或者线程数没调对。还有一个技巧是用 ONNXRuntime 的 profiling 功能在SessionOptions里开EnableProfiling跑几次后会生成一个 JSON 文件里面详细记录了每个算子的耗时。用 Chrome 的chrome://tracing打开就能看到时间线哪个算子最耗时一目了然。这套方案我从 Python 验证到 C 落地走下来最大的体会是预处理和后处理的对齐比模型本身更容易翻车。ONNXRuntime 的推理部分其实很稳只要输入输出对上了结果就不会错。真正花时间的是 letterbox 的 padding 计算、anchor 的解码顺序、NMS 的阈值调参这些「脏活」。建议先把 Python 端跑通把中间结果存下来C 端逐层对比能省很多调试时间。希望帮到你。本文还有配套的精品资源点击获取