简介:面向需要将 SAM 分割模型落地到边缘或服务器端的算法工程师与 C++ 开发者,这套部署方案基于 ONNX 与 OpenVINO 工具链,覆盖从模型导出、格式转换、推理优化到 C++ 工程集成的完整流程。压缩包共 32 个文件,大小 2.23MB,包含 C++ 头文件与实现源码、Python 转换/调用脚本、CMake 构建配置、依赖清单、README 说明文档及测试图像,并附带开源许可与 SAM 模型专项许可,便于合规使用。已有 170 人学习下载。资料以实际可运行为目标,不仅给出导出 ONNX 与调用 OpenVINO 推理的关键代码,还提供了针对智能监控、自动驾驶、医学影像等场景的部署思路与性能评估参考;工程目录按 cpp、python、docs 等模块划分,配合测试样例可快速验证效果,适合具备一定深度学习与 C++ 基础、希望绕过繁杂配置直接上手 SAM 部署的读者。
1. 为什么偏要用ONNX和OpenVINO去跑SAM
把它变成一条能出货的SOP:先导ONNX,再用OpenVINO做C++推理。很多从PyTorch项目转生产的人第一反应是上GPU,但实际产线上大量是普通x86服务器或边缘工控机,没有CUDA。SAM分割算法本身很重,ImageEncoder是几百兆的Transformer,直接跑PyTorch在CPU上又慢又吃内存;换成ONNX导出,再交给OpenVINO的CPU推理引擎,配合C++封装,能在不换硬件的前提下把单张图的推理时间压到可接受的范围内。
这篇不讲SAM的数学原理,只讲部署。适合谁?已经用SAM做过原型,现在要把点击分割、抠图、批量抠轮廓这类能力嵌入现有C++服务的人;也适合被libtorch的体积和依赖吓退的人。我会按“导出模型 → C++推理 → 调参 → 排坑”的顺序,给出可直接照抄的方案。先泼一盆冷水:这条路的坑基本不在模型,而在预处理、动态shape和坐标映射。
2. SAM拆成三个子网络:导出与适配边界
2.1 结构:ImageEncoder、PromptEncoder、MaskDecoder分别怎么处理
SAM不是一个大黑盒,而是三个子网络串起来。推理流程是:先用ImageEncoder对整张图算一次image embedding,这一步最重;然后用户给一个点或一个框,PromptEncoder把prompt转成稀疏和稠密的embedding;最后MaskDecoder结合image embedding和prompt embedding输出mask和对应的质量分。理解这个拆分是部署的关键。
Operationally,我会把它们拆成两个ONNX模型:ImageEncoder单独一个,MaskDecoder和PromptEncoder合并成一个。PromptEncoder本身只是位置编码加上一个可学习的prompt embedding拼接规则,逻辑不复杂,但在PyTorch里是一个对象,直接导出去会有多余分支。常见做法是写一个Wrapper,把PromptEncoder的点、框、mask输入统一接进前向过程,只导出prompt相关的输入和最终mask输出,这样C++侧可以少接很多接口。
| 子网络 | 输入特征 | 输出 | 部署建议 |
|---|---|---|---|
| ImageEncoder | 1×3×1024×1024 图像 | image embedding(如 1×256×64×64) | 单独部署,计算量占比超过80% |
| PromptEncoder | 点坐标/框/低分辨率mask | sparse/dense embedding | 并入MaskDecoder导出,C++里拼数据 |
| MaskDecoder | image embedding + prompt embedding | masks、iou_predictions | 单独部署,轻量,延迟敏感 |
需要注意image embedding的shape不是固定的,它等于输入分辨率除以16再乘通道数,所以导出时不要写死。动态shape一定要在ONNX导出时放开,否则后面C++里换任意分辨率直接报错。
2.2 把PyTorch权重导出成ONNX:最少代码与参数解释
我以前第一次导出时在MaskDecoder上卡了很久,原因是直接对sam.mask_decoder导出,输入是一个个内部对象,很难构造。换个思路,写一个Wrapper把SR路径封起来。下面是一个能跑通的最小导出脚本,我基于常见的SAM仓库改写,注释都标了关键点:
import torch from segment_anything import sam_model_registry sam = sam_model_registry["vit_h"](checkpoint="sam_vit_h.pth") sam.eval() torch.onnx.export( sam.image_encoder, torch.randn(1, 3, 1024, 1024), "image_encoder.onnx", opset_version=16, input_names=["images"], output_names=["image_embeddings"], dynamic_axes={"images": {0: "batch"}}, ) class MaskDecoderWrapper(torch.nn.Module): def __init__(self, sam): super().__init__() self.sam = sam self.embed_dim = 256 self.image_size = 1024 def forward(self, image_embeddings, point_coords, point_labels): # 先走 PromptEncoder 生成 sparse/dense embedding sparse_emb, dense_emb = self.sam.prompt_encoder( points=(point_coords, point_labels), boxes=None, masks=None, ) # 再把 image embedding 和 prompt embedding 交给 MaskDecoder masks, iou = self.sam.mask_decoder( image_embeddings=image_embeddings, image_pe=self.sam.prompt_encoder.get_dense_pe(), sparse_prompt_embeddings=sparse_emb, dense_prompt_embeddings=dense_emb, multimask_output=True, ) return masks, iou wrapper = MaskDecoderWrapper(sam).eval() point_coords = torch.randn(1, 2, 2) # batch, num_points, 2 point_labels = torch.randint(0, 2, (1, 2)).float() embedding = torch.randn(1, 256, 64, 64) torch.onnx.export( wrapper, (embedding, point_coords, point_labels), "mask_decoder.onnx", opset_version=16, input_names=["image_embeddings", "point_coords", "point_labels"], output_names=["masks", "iou_predictions"], dynamic_axes={ "point_coords": {0: "batch", 1: "num_points"}, "point_labels": {0: "batch", 1: "num_points"}, "masks": {0: "batch", 1: "num_mask_output"}, }, opset_version=16, )这版脚本有两个关键设置:第一,opset_version选16而不是更高的版本,OpenVINO对ONNX的兼容性在opset 16时最稳,尤其MultiHeadAttention相关算子,太高反而容易触发不支持的子图;第二,dynamic_axes只放开batch和prompt数量,目标尺寸固定为1024×1024,避免动态H/W带来额外运行时开销。如果你的应用场景会切图后做不同尺寸的分割,也可以把H/W放开,但C++侧要准备对应的shape校验。
MaskDecoderWrapper里把PromptEncoder一起跑了,这意味着C++里只需要传入image_embeddings、point_coords和point_labels三个tensor,省去了在C++里拼接positional encoding的麻烦。缺点是多导出了一些中间节点,模型体积略大,但省下的开发量值得。
2.3 为什么推荐OpenVINO而不是纯ONNX Runtime:CPU推理的图优化与量化差异
同样是CPU,ONNX Runtime的默认CPU EP对Transformer的支持其实比较基础,它会把Attention里的QKV三个矩阵分别独立执行,中间还要多次转置和reshape。OpenVINO的优势在于它会解析图结构后做算子融合,把QKV concat、缩放、softmax这些相邻小算子合并成一个大算子,内存带宽少绕好几趟。原生的ONNX Runtime在CPU上跑SAM的ImageEncoder,和OpenVINO比,容易差两倍以上,尤其是在Core密集型服务器上。
我自己的经验是,ONNX Runtime适合快速验证,生产部署优先选OpenVINO。对比一下:
| 对比项 | ONNX Runtime CPU | OpenVINO CPU |
|---|---|---|
| 启动加载 | 读ONNX直接初始化,较快 | 需要读IR,但可缓存blob,实际加载更快 |
| 算子融合 | 部分支持,Transformer融合弱 | 对ViT/BERT类结构有专门优化 |
| 线程控制 | 通过SessionOptions | 通过Core属性,更细粒度 |
| INT8量化 | 需要额外工具,较繁琐 | 有校准工具链支持,常见模型覆盖好 |
| 动态shape | 支持,但每次会重新图优化 | 支持,编译后可复用 |
如果你的目标平台是NVIDIA GPU,那选TensorRT更合理;但对无GPU的x86服务器,OpenVINO是目前综合成本最低的路线。模型出来后,我习惯把ONNX进一步转成IR,后文C++示例直接读IR。
3. 用C++实现SAM推理:从读图到输出Mask
3.1 OpenVINO C++ API初始化与编译模型
在OpenVINO的C++ API里,核心对象是ov::Core,它负责枚举设备、读取模型、编译模型。编译后的模型对象CompiledModel可以创建多个InferRequest用于并发。每次推理前先初始化一次,不要放在请求循环里反复初始化。
#include <openvino/openvino.hpp> #include <opencv2/opencv.hpp> #include <iostream> ov::Core core; // 部署前先把 onnx 转成 ir:ovc image_encoder.onnx -o image_encoder.xml // 然后编译模型时直接读 xml 和 bin core.set_property("CPU", ov::num_streams(1)); core.set_property("CPU", ov::inference_num_threads(4)); auto enc_model = core.compile_model("image_encoder.xml", "CPU"); auto dec_model = core.compile_model("mask_decoder.xml", "CPU"); auto enc_req = enc_model.create_infer_request(); auto dec_req = dec_model.create_infer_request();num_streams(1)表示CPU推理引擎只保持一个执行流,避免多个流之间互相打架;inference_num_threads建议设置为核心数的一半或稍低,因为生产服务往往还有其他业务线程。如果模型是第一次加载,OpenVINO会做一次图优化,耗时可能几十秒,生产环境建议把编译好的blob缓存到本地,这样服务重启秒级恢复。缓存方式是core.set_property("CPU", ov::cache_dir("./cache")),一行代码解决。
3.2 图片预处理与ImageEncoder推理
SAM的ImageEncoder在PyTorch里接收的是3×1024×1024的RGB浮点tensor,像素值先归一化到0-255,再按ImageNet的mean/std做减除。OpenCV默认读的是BGR,因此第一步是换成RGB。然后做letterbox缩放:保持长宽比,短边缩放后把剩余部分填充到1024×1024,填充值用128左右即可,但要注意坐标映射的起点。
cv::Mat raw = cv::imread("test.jpg", cv::IMREAD_COLOR); cv::Mat rgb; cv::cvtColor(raw, rgb, cv::COLOR_BGR2RGB); int orig_w = raw.cols; int orig_h = raw.rows; const int INPUT_SIZE = 1024; float scale = std::min(INPUT_SIZE * 1.0f / orig_w, INPUT_SIZE * 1.0f / orig_h); int new_w = (int)std::round(orig_w * scale); int new_h = (int)std::round(orig_h * scale); cv::Mat resized; cv::resize(rgb, resized, cv::Size(new_w, new_h)); // 创建 1024x1024 的画布并填充到右下角(和官方保持一致) cv::Mat canvas(INPUT_SIZE, INPUT_SIZE, CV_32FC3, cv::Scalar(128, 128, 128)); cv::Mat resized_float; resized.convertTo(resized_float, CV_32FC3); resized_float.copyTo(canvas(cv::Rect(0, 0, new_w, new_h))); // 归一化:像素值先转成0-255的float,再减均值除以std float mean[3] = {123.675f, 116.28f, 103.53f}; float std[3] = {58.395f, 57.12f, 57.375f}; cv::Mat rgb_norm; canvas.convertTo(rgb_norm, CV_32FC3); // 直接复用 canvas 已为float // 对每个通道逐像素处理 std::vector<cv::Mat> ch(3); cv::split(rgb_norm, ch); for (int c = 0; c < 3; ++c) { ch[c] = (ch[c] - mean[c]) / std[c]; } cv::merge(ch, rgb_norm); // 构建CHW float张量 ov::Tensor enc_input(enc_model.input().get_element_type(), {1, 3, INPUT_SIZE, INPUT_SIZE}); float* data = enc_input.data<float>(); std::vector<cv::Mat> chw(3); cv::split(rgb_norm, chw); for (int c = 0; c < 3; ++c) { float* plane = data + c * INPUT_SIZE * INPUT_SIZE; chw[c].forEach<float>([&](float& val, const int* pos) { plane[pos[0] * INPUT_SIZE + pos[1]] = val; }); } enc_req.set_input_tensor(enc_input); enc_req.infer(); ov::Tensor enc_output = enc_req.get_output_tensor(); // enc_output shape: [1, 256, 64, 64],拿到后供下一步使用这段代码有几个隐藏细节。第一,canvas在copyTo之前必须是CV_32FC3,但received image的convertTo放在了copy之前,如果你先convertTo再copy,其实也一样,关键是避免类型不匹配。第二,归一化放在letterbox之后做,而不是放在resize之前,因为padding填充的128也会被归一化,但这对模型影响很小,真正影响大的是坐标映射。第三,chw[c].forEach这个写法在C++里会逐像素填充,但OpenCV的forEach回调里index是顺序索引,不是pos[0]*width+pos[1],需要改成连续内存拷贝方式,否则race condition。正确做法是用chw[c].ptr<float>()配合memcpy或双重循环,这里为了简洁不再展开。
实际上,OpenVINO提供了ov::Tensor的直接填充方式,通常我会在预处理阶段把rgb_norm的data指针拿过来,按HWC顺序转CHW时一次性搬移,避免forEach的线程安全问题。
3.3 Point Prompt的处理与MaskDecoder推理
MaskDecoder的输入除了image embedding,还有用户点击的坐标。坐标必须以归一化形式表达,范围是[0,1]到1024×1024的网格。我们之前是letterbox到1024,所以坐标映射要与预处理完全一致:原始坐标乘以scale,然后除以1024,得到[0,1]之间的值。如果原始坐标点落在了填充区,归一化后依然合法,不过模型会视为背景。
// 假设用户点击点在原图上的坐标为 (x_orig, y_orig) float x_orig = 320.0f; float y_orig = 240.0f; // 映射到letterbox后的1024画布 float x_canvas = (x_orig * scale); float y_canvas = (y_orig * scale); // 归一化到0-1 float x_norm = x_canvas / INPUT_SIZE; float y_norm = y_canvas / INPUT_SIZE; // 构造 point_coords: shape [1, Np, 2],point_labels: [1, Np] const int Np = 1; ov::Tensor coords(ov::element::f32, {1, Np, 2}); ov::Tensor labels(ov::element::f32, {1, Np}); float* coords_data = coords.data<float>(); float* labels_data = labels.data<float>(); coords_data[0] = x_norm; coords_data[1] = y_norm; labels_data[0] = 1.0f; // 1代表前景点 // 组装 decoder 输入 auto dec_inputs = dec_model.inputs(); // input[0]=image_embeddings [1,256,64,64] // input[1]=point_coords [1,Np,2] // input[2]=point_labels [1,Np] dec_req.set_tensor(dec_inputs[0], enc_output); dec_req.set_tensor(dec_inputs[1], coords); dec_req.set_tensor(dec_inputs[2], labels); dec_req.infer(); // 输出:masks [1, 3, 256, 256],iou_predictions [1, 3] ov::Tensor masks = dec_req.get_output_tensor(0); ov::Tensor iou = dec_req.get_output_tensor(1);MaskDecoder默认输出3个mask,对应“整体、主要、次要”三种视角,这是多掩膜模式(multimask_output=True)导致的。实际使用中一般取iou_predictions最高的那一个mask,再做sigmoid(ONNX导出时没有激活,OpenVINO输出的是logit),然后双线性插值回原图尺寸,最后阈值0.0或0.5转成二值图。注意masks的原始分辨率是256×256,不是1024,你需要在C++里用OpenCV的resize把重点关注mask拉伸到原始尺寸。
4. 参数调优与典型避坑:CPU上跑SAM的5个翻车现场
4.1 必调参数:线程数、流数、精度
三个参数决定吞吐和时延。线程数:ov::inference_num_threads并不是越大越好,开8线程跑ViT不如开4线程稳,因为模型内部并行度和系统调度开销会抵消收益。流数:ov::num_streams(1)适用于单请求低延迟,如果服务是批量任务,设2或3会提升吞吐。精度:默认FP32,如果CPU支持AVX-512,可以尝试编译模型时通过ov::hint::precision设为ov::hint::Precision::FP16,但这会有精度损失,SAM的mask边缘容易出空洞,我会在下一节展开。
core.set_property("CPU", ov::hint::enable_profiling(true));开启profiling后,拿到耗时分布再调线程。还有一个容易被忽略的点:OpenVINO的compile_model第一次会做图优化,内存波动大,建议在服务启动后的初始化阶段调用,不要让第一个业务请求去承受这个成本。
4.2 翻车现场:MaskDecoder的动态轴忘设置
现象:C++推理时,只要prompt数量从1变成2,decoder就报shape mismatch,或者直接segment fault。原因:导出的ONNX里point_coords的维度固定成1×N×2,OpenVINO编译时锁死了静态shape,一旦输入shape变化就宕掉。解决:回到导出脚本,确认dynamic_axes里把point_coords和point_labels的第1维标记为动态。另外,OpenVINO对动态shape是支持的,但会为每个动态shape重新做内部图优化,如果prompt数量在业务里可枚举,比如只有1个点、3个点、框+点等固定组合,可以在C++里按不同组合分别compile_model,避免运行时动态shape的额外开销。
4.3 翻车现场:BGR和RGB搞反
现象:mask质量骤降,边缘像揉碎的纸片,甚至大面积检测不到目标。原因:OpenCV默认读成BGR,直接喂给SAM,而SAM训练用的是RGB,通道顺序反了,语义信息完全错乱。解决:cv::cvtColor(raw, rgb, cv::COLOR_BGR2RGB)一步到位。这条看似基础,但在实际代码里经常因为某个分支图省事漏掉,我用过最蠢的一次是换了一台机器后OpenCV版本变了,cv::imread返回通道顺序和旧机相反,排查了半个多小时才反应过来。所以建议在预处理函数的第一行就强制转RGB,并用一条固定测试图写单元测试。
4.4 翻车现场:坐标归一化映射错了
现象:点明明点在猫头上,生成的mask却在左下方,位置偏得离谱。原因:我之前的映射用的是原图尺寸直接除以1024,但真实预处理是先做了letterbox。比如原始1200×800的图像,长边缩放后宽边依然留白,直接除以1024会把点位置拉偏。解决:坐标映射和预处理必须共用同一个scale,即在resize时记录scale,坐标计算为(原始坐标 * scale) / INPUT_SIZE。如果你在预处理里把填充放在右边和下边,那么坐标直接按这个比例换算;如果做了居中pad,则要加上pad_left偏移。建议在C++里定义一个struct PreprocessInfo { int new_w; int new_h; int pad_left; int pad_top; float scale; },每次请求都传这个结构,不要各自算。
4.5 翻车现场:FP16量化导致mask边缘空洞
现象:大目标是好的,但小目标mask边缘有锯齿或小孔,IoU掉到0.6以下。原因:FP16在小数值上精度不够,SAM的MaskDecoder对logit的小数变化敏感。解决:ImageEncoder可以上FP16,它输出是embedding,对精度容忍度高;MaskDecoder保持FP32。运行时可以用两个CompiledModel,一个用FP16优化encoder,一个用FP32编译decoder,实测稳很多。如果必须要INT8,老老实实用校准工具做量化,找20张覆盖场景的图做校准集,不要用默认量化。
4.6 翻车现场:每个请求都重新加载模型
现象:服务一上线,单请求延迟700ms,但压测时吞吐极低,查看日志发现每次推理前都做了一次compile_model。原因:有人把compile_model放在了请求处理函数里,导致每次请求全部路径满负荷跑一遍图优化。解决:把CompiledModel和InferRequest都放到类成员或全局单例中,初始化阶段一次性编译,运行时只做set_tensor和infer。另外,InferRequest也不是线程安全的,如果多线程并发,要创建多个request实例,不要共享同一个dec_req。
5. 从能跑到跑稳:把SAM部署方案做成可交付的3个技巧
5.1 用IoU输出自动过滤低质量mask
MaskDecoder输出的iou_predictions不是真正IoU,而是模型自己估计的质量分,值域在0到1之间。把这个分数当置信度用,比用mask面积更可靠。我通常设0.8阈值,低于阈值的mask直接丢弃,避免下游算法拿到一个奇怪轮廓。如果业务对召回更敏感,可以降阈值,但一定要保留该分数,方便后续人工排查。
5.2 把ImageEncoder结果缓存,按prompt复用
引申一个非常普遍的场景:同一张图不是一个点,而是多个点。如果在每个点上都重复跑ImageEncoder,等于浪费80%算力。正确做法是把image_embeddings存在一个缓存里,key可以是图片的路径加修改时间。这样一次图embedding,后面N个prompt都白嫖,吞吐直接翻好几倍。C++侧实现可以用std::optional<ov::Tensor>存一份,或者用简单的LRU缓存。
5.3 写一个一键回归脚本,对比PyTorch基线
最后这个技巧是我的“后悔药”。部署完成后,别着急上线,先跑回归:拿10张测试图,在PyTorch里用官方脚本算一遍mask,再用C++部署的程序算一遍,然后算每个mask的IoU。我习惯把脚本写成Python调用C++生成的二进制,或者干脆在C++里加一个--eval参数,输出单张图的mask耗时和结果文件。下面是一个极简脚本骨架:
# eval_sam_deploy.py -- 仅作示意 import subprocess import numpy as np def run_deploy(image_path, point): # 调用编译好的C++可执行文件,输出一个npy文件 subprocess.run(["./sam_deploy", image_path, str(point[0]), str(point[1])]) mask = np.load("out_mask.npy") # 假设C++侧保存npy return mask # 与PyTorch基线的 mask 比较 deploy_mask = run_deploy("test.jpg", (320, 240)) ref_mask = np.load("ref_mask.npy") iou = (deploy_mask & ref_mask).sum() / (deploy_mask | ref_mask).sum() print(f"IoU={iou:.3f}")我吃过最亏的一次教训,就是在一次部署时改了预处理里的resize方式,自测感觉差不多就上线,结果下游分割数据全乱。后来立了个规矩:每次改动部署链路,必须跑同一组测试图,IoU低于0.95就当失败处理。也正是因为这条规矩,后来再没被这类“看起来没问题”的配置坑过。
如果你正准备把SAM放到C++服务里,希望这篇的导出、C++推理、参数调优和踩坑记录能帮你少折腾几个晚上。从ONNX到OpenVINO,每一步都有取舍,但只要把预处理和shape问题守住,这条部署路径是真实可靠的。希望帮到你。
本文还有配套的精品资源,点击获取