简介:这份资源面向计算机视觉工程师与C++开发者,聚焦SAM分割万物模型从训练到落地的部署难题,提供基于ONNX与OpenVINO的完整C++实现方案。内容涵盖模型导出、推理优化与工程集成,可应用于智能视频监控、自动驾驶、医学影像分析等需要快速准确分割关键区域的场景,适合具备一定深度学习与C++基础的中高级读者。资源包共32个文件,约2.23MB,以cpp源码与h头文件承载核心推理逻辑,py脚本负责模型导出与ONNX转换,txt与md提供说明文档,另含license、sam_license等许可文件及CMakeLists构建配置,目录结构清晰便于按模块查阅。目前已有170人学习。读者可从中获得模型转换与部署的完整代码库、针对实际场景的操作指南与性能评估思路,以及项目组织与许可合规的参考,帮助快速搭建可复用的SAM部署流程。
1. 从 PyTorch 到 C++ 推理:SAM 分割模型为什么值得走 ONNX + OpenVINO 这条路
如果你手头有一个跑在 PyTorch 上的 SAM 分割模型,想把它塞进一个不带 Python 环境的 C++ 桌面程序或者边缘设备里,大概率会经历这么一条链路:先导出 ONNX,再用 OpenVINO 转成 IR,最后在 C++ 里加载推理。听起来三步就完事,但真正动手时,编码器导出报错、解码器输入对不上、预处理和后处理全靠手写、内存和耗时翻车,这些坑一个都不会少。SAM 本身是 prompt-based 的分割模型,图像编码器和提示编码器、掩码解码器是分开的,导出策略和普通分类网络完全不是一回事。这篇笔记就按我实际拆过的流程,把 ONNX 导出、OpenVINO 转换、C++ 推理封装、参数配置和常见翻车点讲清楚,适合已经会写 C++、想把这套链路落到工程里的从业者。
2. 拆解 SAM 的导出结构:编码器和解码器为什么要分开导
2.1 SAM 的三段式结构和导出边界
SAM 的推理流程可以拆成三块:图像编码器(Image Encoder,通常是 ViT)、提示编码器(Prompt Encoder,处理点/框/掩码)、掩码解码器(Mask Decoder,输出分割掩码)。图像编码器计算量最大,但一张图只需要跑一次;提示编码器和掩码解码器很轻,但每次换 prompt 都要重跑。所以工程上最常见的做法是:图像编码器单独导出成一个 ONNX,提示编码器和掩码解码器合并导出成另一个 ONNX。这样交互式分割时,图像 embedding 只算一次,后续每次点击只跑轻量解码器,响应能压到几十毫秒级。
如果你把整个 SAM 当成一个模型导出,每次换 prompt 都要重跑 ViT,交互体验直接崩掉。这是第一个选型理由:导出粒度决定了推理架构。
2.2 导出图像编码器的 ONNX
常见做法是用 PyTorch 的torch.onnx.export,把图像编码器包一层,固定输入尺寸。SAM 原版支持 1024x1024 输入,导出时建议固定成常量,避免动态 shape 在 OpenVINO 里引入额外复杂度。
import torch from segment_anything import sam_model_registry # 加载官方权重,vit_b 是最常用的轻量档 sam = sam_model_registry["vit_b"](checkpoint="sam_vit_b_01ec64.pth") sam.eval() # 只取图像编码器,包一层固定输入 class ImageEncoderWrapper(torch.nn.Module): def __init__(self, sam): super().__init__() self.encoder = sam.image_encoder def forward(self, x): # 输出 image embedding,shape 通常是 [1, 256, 64, 64] return self.encoder(x) wrapper = ImageEncoderWrapper(sam).eval() dummy = torch.randn(1, 3, 1024, 1024) torch.onnx.export( wrapper, dummy, "sam_image_encoder.onnx", input_names=["image"], output_names=["image_embedding"], opset_version=17, # 17 对 ViT 里的 attention 算子支持更稳 do_constant_folding=True, dynamic_axes=None # 固定 shape,别开动态 )这段代码的关键点有三个。第一,opset_version=17不是随便选的,ViT 里的MultiHeadAttention和LayerNorm在低版本 opset 下容易导出成奇怪的子图,OpenVINO 转换时会报不支持。第二,dynamic_axes=None是故意的,固定 1024x1024 能让 OpenVINO 做更充分的图优化,动态 shape 留到后面用 reshape 处理。第三,输出image_embedding的 shape 是[1, 256, 64, 64],这个尺寸后面在 C++ 里要用来算 prompt 坐标的缩放比例,记牢。
2.3 导出提示编码器和掩码解码器
解码器部分的导出稍微麻烦一点,因为 SAM 的 prompt 有多种形式:点、框、掩码。工程上一般先支持点和框,掩码 prompt 可以后续再加。导出时把 prompt encoder 和 mask decoder 串起来,输入定义成点坐标、点标签、框坐标。
class PromptDecoderWrapper(torch.nn.Module): def __init__(self, sam): super().__init__() self.prompt_encoder = sam.prompt_encoder self.mask_decoder = sam.mask_decoder def forward(self, image_embedding, point_coords, point_labels, boxes): # sparse embeddings 由点和框生成 sparse, dense = self.prompt_encoder( points=(point_coords, point_labels), boxes=boxes, masks=None ) # 解码出低分辨率掩码 low_res_masks, iou_pred = self.mask_decoder( image_embeddings=image_embedding, image_pe=self.prompt_encoder.get_dense_pe(), sparse_prompt_embeddings=sparse, dense_prompt_embeddings=dense, multimask_output=True ) return low_res_masks, iou_pred wrapper = PromptDecoderWrapper(sam).eval() img_emb = torch.randn(1, 256, 64, 64) pts = torch.randn(1, 2, 2) # 2 个点,每个点 xy labels = torch.ones(1, 2, dtype=torch.int64) boxes = torch.randn(1, 4) # 一个框 xyxy torch.onnx.export( wrapper, (img_emb, pts, labels, boxes), "sam_prompt_decoder.onnx", input_names=["image_embedding", "point_coords", "point_labels", "boxes"], output_names=["low_res_masks", "iou_pred"], opset_version=17, do_constant_folding=True )这里有个血泪经验:point_labels的类型必须是int64,如果你在 C++ 侧传了int32,ONNX Runtime 或 OpenVINO 会在类型检查阶段直接报错,而且报错信息不一定指向类型问题,容易查半天。另外multimask_output=True会输出 3 个候选掩码,C++ 侧要根据iou_pred选最高的那个,这个逻辑别漏。
3. OpenVINO 转换与 C++ 推理封装:从 IR 到可执行程序
3.1 用 mo 把 ONNX 转成 IR
OpenVINO 的模型转换工具叫mo(Model Optimizer),新版本里命令是ovc或者mo,取决于你装的版本。转换本身不复杂,但参数要配对。
# 转换图像编码器,输入是固定 1024x1024 mo --input_model sam_image_encoder.onnx \ --input_shape [1,3,1024,1024] \ --output_dir ir/image_encoder \ --model_name sam_image_encoder \ --compress_to_fp16 # 转换解码器,注意多个输入要分别指定 mo --input_model sam_prompt_decoder.onnx \ --input "image_embedding[1,256,64,64],point_coords[1,-1,2],point_labels[1,-1],boxes[1,4]" \ --output_dir ir/prompt_decoder \ --model_name sam_prompt_decoder \ --compress_to_fp16--compress_to_fp16是默认建议开的,模型体积能砍一半,精度损失在分割任务上通常肉眼看不出来。但如果你后面要做 int8 量化,这一步可以先不压,留 fp32 的 IR 做量化校准。point_coords的-1表示动态维度,因为点的数量不固定,这个动态维度 OpenVINO 是支持的,但 C++ 侧要按实际点数 reshape。
3.2 C++ 侧加载 IR 和推理封装
C++ 推理用 OpenVINO 的 Runtime API,核心是Core、CompiledModel、InferRequest三层。下面是一个最小可用的封装骨架。
#include <openvino/openvino.hpp> #include <opencv2/opencv.hpp> class SamEngine { public: SamEngine(const std::string& encoder_ir, const std::string& decoder_ir) { ov::Core core; // 加载两个 IR,编译到 CPU,也可以换成 GPU auto enc_model = core.read_model(encoder_ir); auto dec_model = core.read_model(decoder_ir); encoder_ = core.compile_model(enc_model, "CPU"); decoder_ = core.compile_model(dec_model, "CPU"); enc_req_ = encoder_.create_infer_request(); dec_req_ = decoder_.create_infer_request(); } // 图像编码,返回 embedding ov::Tensor encode(const cv::Mat& bgr) { cv::Mat rgb, resized; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB); cv::resize(rgb, resized, cv::Size(1024, 1024)); resized.convertTo(resized, CV_32F, 1.0 / 255.0); // 归一化,SAM 用的是 ImageNet mean/std cv::Scalar mean(0.485, 0.456, 0.406); cv::Scalar std(0.229, 0.224, 0.225); resized = (resized - mean) / std; // HWC -> CHW ov::Tensor input(ov::element::f32, {1, 3, 1024, 1024}); float* data = input.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]; enc_req_.set_input_tensor(input); enc_req_.infer(); return enc_req_.get_output_tensor(0); } private: ov::CompiledModel encoder_, decoder_; ov::InferRequest enc_req_, dec_req_; };这段代码里最容易翻车的是预处理。SAM 官方用的是(pixel / 255 - mean) / std,mean 和 std 是 ImageNet 那套。如果你在 Python 侧导出时已经把归一化写进了模型,C++ 侧就不要再做一遍,否则分割结果会整体偏移。我一般会在导出前把预处理固定成模型的一部分,C++ 只负责 resize 和 BGR2RGB,这样两边不容易对不上。
3.3 解码器推理和掩码后处理
解码器推理要传 embedding、点坐标、点标签、框。点坐标需要从原图坐标映射到 1024x1024 的输入坐标系,这个缩放比例是1024 / 原图边长。
ov::Tensor decode(const ov::Tensor& embedding, const std::vector<cv::Point2f>& points, const std::vector<int>& labels, const cv::Rect2f& box) { int n = points.size(); ov::Tensor pt_tensor(ov::element::f32, {1, n, 2}); ov::Tensor lb_tensor(ov::element::i64, {1, n}); ov::Tensor box_tensor(ov::element::f32, {1, 4}); float* pt_data = pt_tensor.data<float>(); int64_t* lb_data = lb_tensor.data<int64_t>(); for (int i = 0; i < n; ++i) { pt_data[i * 2] = points[i].x; pt_data[i * 2 + 1] = points[i].y; lb_data[i] = labels[i]; } float* box_data = box_tensor.data<float>(); box_data[0] = box.x; box_data[1] = box.y; box_data[2] = box.x + box.width; box_data[3] = box.y + box.height; dec_req_.set_input_tensor(0, embedding); dec_req_.set_input_tensor(1, pt_tensor); dec_req_.set_input_tensor(2, lb_tensor); dec_req_.set_input_tensor(3, box_tensor); dec_req_.infer(); return dec_req_.get_output_tensor(0); // low_res_masks }后处理要做三件事:从 3 个候选掩码里按 iou_pred 选最好的、把低分辨率掩码上采样回原图尺寸、按阈值二值化。低分辨率掩码通常是 256x256,上采样用双线性插值,阈值一般取 0.0(SAM 输出的是 logits,不是概率),二值化后就是最终掩码。
4. 避坑与排查:SAM 部署里最容易翻车的五个点
4.1 导出时报 "Unsupported operator" 或 attention 子图异常
现象:torch.onnx.export过程中报某个算子不支持,或者导出的 ONNX 在 OpenVINO 转换时报 attention 相关节点无法映射。原因通常是 opset 版本太低,ViT 里的scaled_dot_product_attention在 opset 14 以下没有对应实现。解决办法是把opset_version提到 17,如果还报错,就在导出前把 attention 换成手动实现的matmul + softmax版本,牺牲一点速度换兼容性。
4.2 C++ 侧输入类型不匹配导致推理直接崩
现象:程序在set_input_tensor或infer时抛异常,提示 element type mismatch。原因多半是point_labels传了int32而模型期望int64,或者图像输入传了uint8而模型期望f32。解决办法是导出后用 Netron 打开 ONNX 看一眼每个输入的类型和 shape,C++ 侧严格按这个来。我一般会在封装层加一个类型断言,早报错早定位。
4.3 分割结果整体偏移或全黑
现象:推理能跑通,但输出的掩码要么整体偏移,要么全是背景。原因通常是预处理不一致:Python 导出时做了归一化,C++ 又做了一遍;或者 BGR/RGB 通道顺序搞反了。解决办法是固定预处理归属,要么全在模型里,要么全在 C++ 里,别两边都做。通道顺序用一张纯色图测一下,红色图如果输出异常,基本就是通道反了。
4.4 交互式分割响应慢
现象:每次点击都要等好几秒。原因是没有复用图像 embedding,每次点击都重跑了图像编码器。解决办法是把encode和decode拆成两个独立调用,图像 embedding 缓存起来,点击时只跑解码器。vit_b 的图像编码器在 CPU 上大概几百毫秒,解码器只有几十毫秒,拆开之后交互体验完全不一样。
4.5 int8 量化后精度掉得厉害
现象:用 NNCF 或 POT 做了 int8 量化,模型体积小了,但掩码边缘变得很毛糙。原因是量化校准集选得不对,或者对解码器也做了量化。常见做法是只量化图像编码器,解码器保持 fp16,校准集用几十张真实场景图,别用随机噪声。如果精度还是不行,就退回 fp16,体积和速度的平衡点通常在 fp16 上。
5. 进阶技巧:用动态 shape 和缓存把交互体验再压一档
前面讲的都是固定 shape 的版本,实际用起来还有一个优化空间:把解码器的点数量维度做成真正的动态,配合 OpenVINO 的reshape和set_tensor按需分配,避免每次传固定长度的数组。另外图像 embedding 的缓存策略也值得细说,我一般会用一个std::unordered_map按图像哈希缓存 embedding,同一张图反复交互时直接命中缓存。
// 解码器动态 reshape,按实际点数调整 ov::Tensor decode_dynamic(const ov::Tensor& embedding, const std::vector<cv::Point2f>& points, const std::vector<int>& labels) { int n = points.size(); // 按实际点数 reshape 输入 dec_model_.reshape({{1, 256, 64, 64}, {1, n, 2}, {1, n}, {1, 4}}); auto req = dec_model_.create_infer_request(); ov::Tensor pt_tensor(ov::element::f32, {1, n, 2}); ov::Tensor lb_tensor(ov::element::i64, {1, n}); // ... 填充数据同上 req.set_input_tensor(0, embedding); req.set_input_tensor(1, pt_tensor); req.set_input_tensor(2, lb_tensor); req.infer(); return req.get_output_tensor(0); }动态 reshape 的代价是每次 reshape 会触发一次图重编译,如果点数变化频繁,反而更慢。所以我的习惯是:点数在 1 到 8 之间时用固定 shape 的 8 点版本,超过 8 点才走动态。这样大部分交互场景都命中固定 shape,速度最稳。
验证方法上,我一般会准备三张图:一张纯色图验证预处理、一张有明显前景背景的图验证分割质量、一张 4K 大图验证缩放逻辑。三张图跑通,基本能覆盖 90% 的部署问题。另外 OpenVINO 自带的benchmark_app可以用来单独测编码器和解码器的耗时,定位瓶颈很快。
从那以后我每次导出 SAM 的 ONNX,都会先用 Netron 把输入输出类型和 shape 截图存下来,C++ 侧照着截图写,再也没在类型不匹配上翻过车。希望帮到你。
本文还有配套的精品资源,点击获取