简介:面向算法部署工程师与C++开发者的SAM图像分割模型落地项目,完整演示如何基于ONNX模型中间表示、OpenVINO推理引擎与C++应用层,将Segment Anything Model高效集成至实际业务场景,解决深度学习模型跨框架、跨硬件部署的常见难题。资源共23个文件,总大小2.22MB:7个txt记录配置参数与运行说明,5个h头文件和4个cpp源文件构成C++推理主程序,4个py脚本覆盖模型导出与预处理流程,另附1张jpg和1张png测试图像及md格式流程文档,目录结构清晰,便于按模块研读。当前已有221人学习下载,适合希望快速掌握ONNX模型转换、OpenVINO优化及C++高性能调用的中高级开发者。通过完整源码与分步教程,可系统理解SAM模型从训练成果到可部署应用的转化链条,涵盖环境准备、依赖安装、模型优化到C++加载推理的关键环节,直接复用工程框架,显著缩短图像分割功能的上线周期。
1. 为什么是 ONNX+OpenVINO+Cpp 这条链:SAM 部署的选型逻辑
把 SAM 分割万物算法真正落到服务端或边缘设备上,主流工程路线就是 ONNX+OpenVINO+Cpp:先用 PyTorch 导出 ONNX 模型,再用 OpenVINO 做推理加速,最后用 C++ 封装成可嵌入的接口。三条链路各自解决一个真实问题——ONNX 负责把模型从训练框架里解放出来,OpenVINO 负责在 Intel CPU/核显上跑出可用性能,C++ 负责把推理逻辑并进现有服务或上位机程序。这个组合特别适合那些不能上 Python 运行时、又没预算买独立 GPU 的落地场景。适合谁?算法工程师需要交付一个 exe 或 so,后端开发需要把 SAM 接进图像流水线,或者是刚接手这类“带源码+教程”实战包的入门者。这篇不聊论文,只讲怎么把模型导出、怎么调 OpenVINO、怎么用 C++ 把 mask 算出来。
2. 把 SAM 从 PyTorch 转到 ONNX:导出脚本与三个关键开关
2.1 先搞清楚 SAM 拆成哪几块再导出
网上很多“pytorch转onnx”教程拿 YOLO 举例,一个模型 forward 到底就完事了。SAM 完全不是这个思路,它内部是三个子网络拼起来的:image encoder(ViT)、prompt encoder(点/框编码)、mask decoder(生成 mask)。直接对整个 SAM 模型做torch.onnx.export通常会炸在动态点坐标上,而且整包导出的模型在 OpenVINO 里会被当成一个巨大的黑匣子,量化、静态 shape、算子替换全都受限制。
我一般会按部署目标把 SAM 拆成两个 ONNX:一个只装 image encoder,输入 1024x1024 图像,输出 image embedding;另一个把 prompt encoder 和 mask decoder 打包,输入 image embedding 加点的坐标和标签,输出低分辨率 mask 和 iou 分数。拆成两个的好处是 image encoder 只在图像变化时跑一次,用户连续点几十个点,只需要重复跑后半个模型,交互延迟能低一截。
| 导出方案 | 模型数 | 动态维度 | 适合场景 |
|---|---|---|---|
| 整包导出 | 1 个 | 点和 mask 全动态,OpenVINO 难优化 | 只做离线批量分割,不追求交互 |
| 双模型拆分 | 2 个 | 仅 prompt 的 N 维动态 | 交互式分割、服务端常驻 |
2.2 pytorch转onnx 导出脚本:image encoder 与 mask decoder
先导出 image encoder。这里第一个关键开关是opset_version,OpenVINO 2023 之后的版本对 opset 15 支持最稳,别为了追新用 17,反而可能踩到新算子的转换问题。第二个关键开关是把图像的宽高固定死为 1024,只放开 batch 维度。SAM 的 ViT 内部有位置编码,形状变了位置编码就对不上,动态高宽没有意义。
import torch from segment_anything import sam_model_registry sam = sam_model_registry["vit_b"](checkpoint="你的vit_b权重路径") sam.eval() # 只导出 image encoder,输出 embedding image_encoder = sam.image_encoder dummy_input = torch.randn(1, 3, 1024, 1024) torch.onnx.export( image_encoder, dummy_input, "sam_image_encoder.onnx", input_names=["image"], output_names=["image_embeddings"], opset_version=15, dynamic_axes={"image": {0: "batch"}}, )dynamic_axes只给 batch 维度,图像尺寸锁死在 1024。这样导出的 ONNX 在 OpenVINO 里可以直接做静态 shape 优化,推理速度和显存占用都可控。batch 动态是给多图批处理留的口子,实际项目里如果一次只想处理一张图,可以连 batch 也锁死,OpenVINO 编译出来的模型会更干净。
接着导出 mask decoder。这一半容易翻车:point_coords 的点数 N 在交互阶段是不确定的,必须动态;但 mask_input 不是每次都要用,SAM 支持迭代式 refine,第一次推理没有上一轮 mask,习惯做法是导出一个固定形状的全零 tensor 占位。label 维度也要跟着 N 走。
# 从 SamPredictor 里取 prompt_encoder 和 mask_decoder from segment_anything import SamPredictor predictor = SamPredictor(sam) prompt_encoder = predictor.model.prompt_encoder mask_decoder = predictor.model.mask_decoder # 输入的 embedding 来自 image encoder,1x256x64x64 image_embedding = torch.randn(1, 256, 64, 64) # 两个点:第一个是正点(label=1),第二个是负点(label=0) point_coords = torch.tensor([[[100.0, 200.0], [300.0, 400.0]]], dtype=torch.float) point_labels = torch.tensor([[1, 0]], dtype=torch.float) mask_input = torch.zeros(1, 1, 256, 256, dtype=torch.float) has_mask_input = torch.zeros(1, 1, dtype=torch.float) torch.onnx.export( mask_decoder, (image_embedding, point_coords, point_labels, mask_input, has_mask_input), "sam_mask_decoder.onnx", input_names=["image_embeddings", "point_coords", "point_labels", "mask_input", "has_mask_input"], output_names=["masks", "iou_predictions"], opset_version=15, dynamic_axes={ "point_coords": {1: "num_points"}, "point_labels": {1: "num_points"}, }, )注意point_labels用 float,不是 int,SAM 源码里 label 张量走的是 float 运算,C++ 端填数据时也要用 float 数组。输出masks的形状是 1x2x3x256x256,其中 2 是点数,3 是每个点给出的三个候选 mask(whole、part、subpart),iou_predictions对应每个 mask 的分数,C++ 端要拿这个分数挑最优候选。
2.3 导完先验证再交给 OpenVINO
很多项目源码里导出脚本写完就直接扔给 OpenVINO,结果 OpenVINO 报了算子不支持,又回头改导出代码,来回折腾。我习惯在导出后先用 onnxruntime 跑一遍,对照 PyTorch 的输出,确认导出这一步没丢精度。验证脚本的逻辑是:同一张图和同一个点,比较 PyTorch 输出的 mask 和 ONNX 输出的 mask 的 IoU。
import numpy as np import onnxruntime as ort from segment_anything import sam_model_registry, SamPredictor # 先跑 PyTorch 得到参考输出 predictor.set_image(image_np) masks_pt, scores_pt, _ = predictor.predict( point_coords=np.array([[100, 200]]), point_labels=np.array([1]), ) # 再跑 ONNX Runtime sess = ort.InferenceSession("sam_mask_decoder.onnx", providers=["CPUExecutionProvider"]) onnx_out = sess.run( None, { "image_embeddings": embedding_np, "point_coords": np.array([[[100.0, 200.0]]], dtype=np.float32), "point_labels": np.array([[1.0]], dtype=np.float32), "mask_input": np.zeros((1, 1, 256, 256), dtype=np.float32), "has_mask_input": np.zeros((1, 1), dtype=np.float32), }, )对比时重点看两点:一是masks经过 sigmoid 后和 PyTorch 的masks_pt是否接近,IoU 低于 0.99 就要怀疑导出时某个输入张量的默认值不对;二是iou_predictions的排序是否一致,如果分数最高的候选 mask 和 PyTorch 选出来的不是同一个,说明模型的数值精度出了问题,这种情况最常见的原因是 ONNX 里某些算子在低精度下丢位,后面 OpenVINO 阶段会放大这个误差。
3. OpenVINO 模型处理:直接读 ONNX 还是转 IR、静态化与 INT8 量化
3.1 直接读 ONNX 与转 IR 的取舍
拿到导出的两个 ONNX 文件后,OpenVINO 给了两条路:用ov::Core::read_model直接读 ONNX,或者先用ovc(新版 Model Optimizer)把 ONNX 转成 IR 的.xml和.bin再读。早期 OpenVINO 版本里直接读 ONNX 的算子支持不全,转 IR 是必经步骤;2023 之后的版本我实测直接读 ONNX 已经覆盖绝大多数算子,而且省一步转换,部署目录里文件也更少。
| 接入方式 | 优点 | 缺点 |
|---|---|---|
| 直接读 ONNX | 部署目录干净,换模型方便 | 每次启动都做一次内部转换,稍微拖慢载入 |
| 先 ovc 转 IR | 启动快,方便固定静态 shape | 多一个转换步骤,模型更新要重新转 |
我的习惯是开发阶段直接读 ONNX,跑通了再考虑要不要转 IR。如果一个模型在 OpenVINO 每次read_model都要两三秒,而服务频繁重启,那转 IR 是值得的。
# 先用 ovc 把 ONNX 转成 IR,固定 batch ovc sam_image_encoder.onnx --output_dir ir_models --input "image[1,3,1024,1024]"--input后面的形状格式是名称[维度],多个输入用逗号分隔。转完后目录里会生成.xml和.bin,C++ 里用read_model("ir_models/sam_image_encoder.xml")加载。注意ovc在 OpenVINO 2024 版本里会提示用openvino_model这个新格式,IR 仍然被完整支持,存量项目不用着急迁移。
3.2 静态 shape 与输入输出约定
OpenVINO 读进 ONNX 后,默认保留模型里的动态维度。image encoder 只有一个动态 batch,mask decoder 的 num_points 是动态的。动态维度会让 OpenVINO 在运行时维护额外的形状推断逻辑,延迟能差 20% 上下。我一般在加载后立刻用reshape把能静态的维度全部锁死。
ov::Core core; auto model = core.read_model("sam_mask_decoder.onnx"); // 把 num_points 锁死成 2,batch 保持 1 std::map<std::string, ov::PartialShape> shape_map; shape_map["point_coords"] = ov::PartialShape{1, 2, 2}; shape_map["point_labels"] = ov::PartialShape{1, 2}; model->reshape(shape_map); auto compiled = core.compile_model(model, "CPU");这里有个取舍:锁死 N=2,意味着每次推理只能传两个点。如果产品允许用户一次框选多个点,可以改成 N=8 或 N=16,超出部分用重复点补齐,或者在 C++ 层做分批调用。把动态维度全部消掉之后,OpenVINO 内部会把算子图做一次常量折叠和内存规划,实测单次推理能快 15% 到 30%,这在交互式分割里体感非常明显。
3.3 ONNX 量化 int8 什么时候值得做
标题里带着“.onnx量化int8”这个热词,说明不少人在搜 SAM 的 int8 量化。但 SAM 的 image encoder 是 ViT 结构,对量化非常敏感,直接 PTQ 很容易把 mask 边界搞得稀烂。我的经验是:先跑 FP32/FP16,确认整条链路精度没问题,再考虑 int8。如果目标设备是第 12 代之后的 Intel CPU,OpenVINO 的 FP16 已经能利用 AVX512,很多场景根本不需要 int8。
如果确实要压内存或追求极限吞吐,用 OpenVINO 的 NNCF 做量化训练后校准。校准集别随便拿 20 张图凑数,至少要 50 张覆盖你要分割的目标类别。int8 精度验证不能只测单张图的 IoU,要看 5 到 10 张图上所有 prompt 点的平均交并比变化,掉点超过 2% 就建议只量化 image encoder,mask decoder 保持 FP16。
# 用 NNCF 提供的 PTQ 脚本做 int8 量化,校准数据用 COCO 子集 nncf_ptq.py \ --model sam_image_encoder.onnx \ --calibration-data coco_subset \ --output sam_image_encoder_int8.onnx量化完的 ONNX 同样可以直接交给 OpenVINO 读。我踩过的坑是:NNCF 量化后的模型如果用ovc再转一次 IR,有时会丢精度,建议直接让 OpenVINO 读量化后的 ONNX,别叠两层转换。
4. 用 C++ 跑通 OpenVINO 推理:预处理、推理与 mask 后处理
4.1 工程骨架:OpenVINO C++ API 的最小结构
拿到这类“带源码+流程教程”的实战包,第一步先确认三件事:导出脚本能不能跑出 ONNX、模型文件放在哪个目录、CMake 里 OpenVINO 路径是否指向你本机的 OpenVINO。OpenVINO 的 C++ API 在 2023 之后改成了ov::命名空间,老项目里常见的InferenceEngine::已经废弃,新写的部署代码直接按新 API 来。
#include <openvino/openvino.hpp> #include <opencv2/opencv.hpp> int main() { ov::Core core; // CPU 上编译模型 auto compiled_encoder = core.compile_model( core.read_model("sam_image_encoder.onnx"), "CPU"); // 创建推理请求,OpenVINO 里叫 InferRequest auto infer_request = compiled_encoder.create_infer_request(); return 0; }compile_model只做一次,服务启动后复用。注意 OpenVINO 的read_model和compile_model分离得很清楚,前者是解析模型,后者是把模型编译成目标设备可执行的格式。如果同一份模型要被多路并发用,可以编译一次,然后创建多个InferRequest,每个请求独占一套输入输出缓冲,比每路请求都重复 compile 省得多。
4.2 图像预处理与 blob 填充:SAM 不是 YOLO,别用同一套归一化
OpenCV 读进来的图像是 HWC 且像素值 0-255,SAM 训练时的预处理是:先除以 255 归一化到 0-1,再按 ImageNet 的 mean/std 标准化。很多项目从 YOLO 迁移到 SAM,直接把 YOLO 的除以255拿过来用,结果分割出来的 mask 完全不对——因为 YOLO 不做 mean/std 标准化,SAM 少这一步输入的分布就会偏移。
cv::Mat image = cv::imread("test.jpg"); cv::Mat rgb, resized; cv::cvtColor(image, rgb, cv::COLOR_BGR2RGB); cv::resize(rgb, resized, cv::Size(1024, 1024), 0, 0, cv::INTER_LINEAR); // 转为 float 并归一化到 0-1 resized.convertTo(resized, CV_32FC3, 1.0 / 255.0); // 手动按通道减均值除方差 const float mean[] = {0.485f, 0.456f, 0.406f}; const float std[] = {0.229f, 0.224f, 0.225f}; std::vector<cv::Mat> ch(3); cv::split(resized, ch); for (int i = 0; i < 3; i++) { ch[i] = (ch[i] - mean[i]) / std[i]; } cv::merge(ch, resized); // HWC 转 NCHW 并灌入 tensor ov::Shape shape{1, 3, 1024, 1024}; ov::Tensor input_tensor(ov::element::f32, shape); float* data = input_tensor.data<float>(); for (int c = 0; c < 3; c++) for (int h = 0; h < 1024; h++) for (int w = 0; w < 1024; w++) data[c * 1024 * 1024 + h * 1024 + w] = resized.at<cv::Vec3f>(h, w)[c];convertTo的缩放系数是1.0/255,不是1.0/255.0的整数除法——这个细节写过 C++ 的人都知道容易错。HWC 到 NCHW 的转置用双重循环看似笨重,但 1024x1024 的图三次循环在 Release 编译下耗时不到 5ms,优先保证正确性。OpenVINO 的ov::Tensor数据内存是连续的,直接拿data<float>()指针逐通道拷进去就行。
4.3 推理与 mask 后处理:从 logits 到二值图再到叠加显示
image encoder 推理完,输出 embedding 直接喂给 mask decoder。这一步要注意:embedding 的 tensor 生命周期要跨越两次推理,所以不能复用同一块缓冲区,要么把 image encoder 的输出数据拷进自己的 vector,要么持有两个独立的 InferRequest。
auto encoder_output = infer_encoder.get_output_tensor(); // 复制 embedding 到 mask decoder 的输入 ov::Tensor emb_tensor = infer_decoder.get_input_tensor(0); memcpy(emb_tensor.data<float>(), encoder_output.data<float>(), encoder_output.get_byte_size()); // 设置点的坐标和 label float points_data[2][2] = {{100.0f, 200.0f}, {300.0f, 400.0f}}; ov::Tensor points_tensor = infer_decoder.get_input_tensor(1); memcpy(points_tensor.data<float>(), points_data, sizeof(points_data)); float labels_data[2] = {1.0f, 0.0f}; ov::Tensor labels_tensor = infer_decoder.get_input_tensor(2); memcpy(labels_tensor.data<float>(), labels_data, sizeof(labels_data)); infer_decoder.infer();get_input_tensor(i)返回的是当前 InferRequest 绑定的输入 tensor,直接用memcpy填充比set_tensor重新绑一个 tensor 省去一次内部的张量校验开销。填充完调用infer(),同步推理。这里的->运算符是访问 tensor 成员的标准方式,OpenVINO 里 tensor 是智能指针封装,不要手动 delete。
后处理是大多数自研工程翻车的重灾区。model decoder 输出的masks是 logits 形状 1x2x3x256x256,先按 iou_predictions 选分数最高的那路 mask,再对 logits 做阈值。SAM 的默认 mask 阈值是 0.0,也就是 logits 大于 0 的像素算前景,不是很多人以为的 0.5。
// 拿 iou 分数,选最高分对应的 mask 索引 auto scores_tensor = infer_decoder.get_output_tensor(1); const float* scores = scores_tensor.data<float>(); // 输出布局: [batch, num_points, num_candidates],这里取第一个点的 3 个候选 int best_idx = 0; float best_score = scores[0]; for (int i = 1; i < 3; i++) { if (scores[i] > best_score) { best_score = scores[i]; best_idx = i; } } // 取对应 mask 的 logits,阈值 0.0 转二值 auto masks_tensor = infer_decoder.get_output_tensor(0); const float* masks = masks_tensor.data<float>(); const float* best_mask = masks + best_idx * 256 * 256; cv::Mat mask_mat(256, 256, CV_32FC1, const_cast<float*>(best_mask)); cv::Mat binary; cv::threshold(mask_mat, binary, 0.0, 1.0, cv::THRESH_BINARY); cv::Mat resized_mask; cv::resize(binary, resized_mask, cv::Size(orig_w, orig_h), 0, 0, cv::INTER_NEAREST);cv::threshold的第一个参数是 logits,不是 sigmoid 之后的值,因为阈值 0.0 和sigmoid(x) > 0.5完全等价。从 256x256 放大回原图用INTER_NEAREST,如果这里用了INTER_LINEAR,mask 边缘会出现半透明的过渡带,叠加显示时看起来像被腐蚀了一圈。
5. 部署避坑:SAM 在 ONNX 链路上的 5 个血泪经验
5.1 坑一:动态维度没锁干净,OpenVINO 编译报 shape mismatch
现象:compile_model报错或者运行时infer()抛出异常,提示某个输入 shape 与模型不匹配。
原因:ONNX 里的动态维度是[1, dynamic, 2]这种带符号的形状,OpenVINO 默认沿用。C++ 端如果用get_input_tensor(1)填了一个固定 2 的数组,运行时会做一次隐式校验,对不上就抛异常。
解决:在read_model之后马上reshape,把num_points锁死成你的实际调用形状,同时把mask_input和has_mask_input这两个输入用set_tensor绑成固定形状的零张量,别让它们在每次推理时重新做形状推断。
5.2 坑二:点坐标没有映射回 1024 坐标系
现象:点明明点在目标物体上,分割出来的 mask 却是旁边另一块区域,而且点的位置离得越远,偏差越明显。
原因:SAM 训练时点坐标是在 1024x1024 的输入图上定义的,图像推理前先resize到 1024 再跑。如果你在原始 1920x1080 图上取值,直接填进point_coords,坐标就是错的,需要乘以缩放系数。
解决:算好比例scale_x = 1024.0 / orig_w,scale_y = 1024.0 / orig_h,把原始坐标换算后再填 tensor。反过来,后处理 mask 从 1024 坐标系搞回原图坐标时,也要除以同样的系数。这个映射关系写进工具函数里,所有调用点统一走它,别在每个地方手工乘除。
5.3 坑三:mask 后处理的阈值用错
现象:分割结果边界很脏,要么大片空洞,要么整个前景都在。
原因:把 SAM 的 mask 当成普通分割模型的输出,用了 0.5 做阈值。SAM 的 mask 分支输出的是 logits,不是 sigmoid 概率,默认阈值是 0.0。如果输出层在导出时被某些工具自动加了 sigmoid,你又按 logits 取 0.0,那等于取到全背景。
解决:先用 onnxruntime 打印输出 tensor 的数值范围。如果输出值在 [-6, 6] 附近,说明是 logits,阈值 0.0;如果输出全在 [0, 1],说明导出时已经被加过 sigmoid,阈值用 0.5。这两个场景的代码写法完全不同,验证脚本里最好把输出分布打出来看一眼。
5.4 坑四:预处理少了 mean/std 标准化
现象:推理结果像是随机噪声,mask 零散分布在图像各处,框里的物体边缘完全没分割出来。
原因:参考了 YOLO 的部署代码,只做了像素 / 255就把数据喂进去了。SAM 的 image encoder 对输入分布极敏感,少了 ImageNet 标准化,ViT 里的 LayerNorm 会把输入分布推到完全不同的区间。
解决:按第 4 章的预处理代码严格做三步:归一化到 0-1、按通道减均值、除方差。这三个操作的系数来自 SAM 官方源码,别凭感觉替换成别的 mean/std。有个排查技巧:把预处理后的图写出来和 PyTorch 预处理函数的结果逐像素比对,10 分钟内能定位问题。
5.5 坑五:OpenVINO 推理结果和 ONNX Runtime 不一致
现象:同一个模型、同一份输入,ONNX Runtime 跑出来 mask 边界清晰,OpenVINO 跑出来边界偏粗或者有少量杂点。
原因:OpenVINO 在 CPU 上默认启用一些融合和精度优化,FP16 中间张量会引入微小误差。SAM 的 mask 分支对数值精度不算特别敏感,但边界像素的 logits 经常落在 0 附近,微小波动会把本来该是前景的像素翻到背景。
解决:先用 OpenVINO 的compile_model设置"CPU_FP16"为"NO",对比 FP32 推理结果。如果 FP32 正常,说明是 FP16 的精度问题,再评估能不能接受。接受的话,在阈值化之前对 logits 做一次 3x3 中值滤波,能把边界孤立点的噪声压掉,代价是边缘损失 1-2 个像素。
6. 性能调优与验证:从 3 秒到 300ms 的调整顺序
6.1 调优不必玄学,按这张顺序表来
SAM 的部署性能瓶颈几乎永远在 image encoder,mask decoder 是纯卷积加 upsample,占比不到 10%。调优要按收益从大到小排,别一上来就上 int8 量化。
| 优化手段 | 预期收益 | 风险 |
|---|---|---|
| 静态 shape | 减少动态推断和内存重排,15%-30% | 几乎无风险 |
| CPU 线程数调优 | 多核场景提升明显,单核无收益 | 线程开太多反而掉速 |
| FP16 开启 | 30%-50% 吞吐提升 | 边界精度可能微降 |
| int8 量化 | 极限吞吐提升,但容量翻倍 | image encoder 掉点风险高 |
6.2 用 benchmark_app 快速定位瓶颈
不知道瓶颈在哪的时候,直接用 OpenVINO 自带的 benchmark_app 测单模型。不要靠猜,测完数据再决定优化方向。
benchmark_app -m sam_image_encoder.onnx -shape "image[1,3,1024,1024]" -d CPU -t 10看三个指标:latency是单次推理延迟,决定交互体验;throughput是吞吐,决定并发能力;actual CPU utilization如果没吃满,说明线程数设置不合理,可以在compile_model时加ov::num_streams(4)试试。如果 image encoder 跑到 300ms 以下,CLI 端整条链路就能做到秒级响应。
6.3 验证技巧:两点提示对比 PyTorch 与 OpenVINO 的 mask IoU
最后给一个我一直用的验收方法。选 10 张覆盖不同场景的图,每张图给两个固定点(一个正点一个负点),用 PyTorch 跑一遍得到参考 mask,再用 C++ 部署链路跑一遍。计算两个 mask 的 IoU,要求平均值大于 0.95。如果某张图低于这个值,单独打印这张图的 logits 分布和中间层输出,定位是预处理问题还是 OpenVINO 精度问题。IoU 验证脚本写成命令行工具,每次修改预处理、模型或量化参数后都跑一遍,防止“改一处坏全局”。这套验证从第一次部署到最后一次交付都应该存在,我因为偷懒跳过它吃过一次亏:上线前发现分割边界全偏了 8 个像素,重新对比才发现是 OpenVINO 读 ONNX 时默认 layout 和 OpenCV 的通道顺序不一致。从那以后我养成了先写验证脚本、再优化性能的习惯。希望帮到你。
本文还有配套的精品资源,点击获取