简介面向需要将 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侧可以少接很多接口。子网络输入特征输出部署建议ImageEncoder1×3×1024×1024 图像image embedding如 1×256×64×64单独部署计算量占比超过80%PromptEncoder点坐标/框/低分辨率masksparse/dense embedding并入MaskDecoder导出C里拼数据MaskDecoderimage embedding prompt embeddingmasks、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](checkpointsam_vit_h.pth) sam.eval() torch.onnx.export( sam.image_encoder, torch.randn(1, 3, 1024, 1024), image_encoder.onnx, opset_version16, 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), boxesNone, masksNone, ) # 再把 image embedding 和 prompt embedding 交给 MaskDecoder masks, iou self.sam.mask_decoder( image_embeddingsimage_embeddings, image_peself.sam.prompt_encoder.get_dense_pe(), sparse_prompt_embeddingssparse_emb, dense_prompt_embeddingsdense_emb, multimask_outputTrue, ) 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_version16, 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_version16, )这版脚本有两个关键设置第一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 RuntimeCPU推理的图优化与量化差异同样是CPUONNX Runtime的默认CPU EP对Transformer的支持其实比较基础它会把Attention里的QKV三个矩阵分别独立执行中间还要多次转置和reshape。OpenVINO的优势在于它会解析图结构后做算子融合把QKV concat、缩放、softmax这些相邻小算子合并成一个大算子内存带宽少绕好几趟。原生的ONNX Runtime在CPU上跑SAM的ImageEncoder和OpenVINO比容易差两倍以上尤其是在Core密集型服务器上。我自己的经验是ONNX Runtime适合快速验证生产部署优先选OpenVINO。对比一下对比项ONNX Runtime CPUOpenVINO CPU启动加载读ONNX直接初始化较快需要读IR但可缓存blob实际加载更快算子融合部分支持Transformer融合弱对ViT/BERT类结构有专门优化线程控制通过SessionOptions通过Core属性更细粒度INT8量化需要额外工具较繁琐有校准工具链支持常见模型覆盖好动态shape支持但每次会重新图优化支持编译后可复用如果你的目标平台是NVIDIA GPU那选TensorRT更合理但对无GPU的x86服务器OpenVINO是目前综合成本最低的路线。模型出来后我习惯把ONNX进一步转成IR后文C示例直接读IR。3. 用C实现SAM推理从读图到输出Mask3.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 转成 irovc 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::vectorcv::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.datafloat(); std::vectorcv::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].forEachfloat([](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]*widthpos[1]需要改成连续内存拷贝方式否则race condition。正确做法是用chw[c].ptrfloat()配合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.datafloat(); float* labels_data labels.datafloat(); 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_outputTrue导致的。实际使用中一般取iou_predictions最高的那一个mask再做sigmoidONNX导出时没有激活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变成2decoder就报shape mismatch或者直接segment fault。原因导出的ONNX里point_coords的维度固定成1×N×2OpenVINO编译时锁死了静态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输出自动过滤低质量maskMaskDecoder输出的iou_predictions不是真正IoU而是模型自己估计的质量分值域在0到1之间。把这个分数当置信度用比用mask面积更可靠。我通常设0.8阈值低于阈值的mask直接丢弃避免下游算法拿到一个奇怪轮廓。如果业务对召回更敏感可以降阈值但一定要保留该分数方便后续人工排查。5.2 把ImageEncoder结果缓存按prompt复用引申一个非常普遍的场景同一张图不是一个点而是多个点。如果在每个点上都重复跑ImageEncoder等于浪费80%算力。正确做法是把image_embeddings存在一个缓存里key可以是图片的路径加修改时间。这样一次图embedding后面N个prompt都白嫖吞吐直接翻好几倍。C侧实现可以用std::optionalov::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(fIoU{iou:.3f})我吃过最亏的一次教训就是在一次部署时改了预处理里的resize方式自测感觉差不多就上线结果下游分割数据全乱。后来立了个规矩每次改动部署链路必须跑同一组测试图IoU低于0.95就当失败处理。也正是因为这条规矩后来再没被这类“看起来没问题”的配置坑过。如果你正准备把SAM放到C服务里希望这篇的导出、C推理、参数调优和踩坑记录能帮你少折腾几个晚上。从ONNX到OpenVINO每一步都有取舍但只要把预处理和shape问题守住这条部署路径是真实可靠的。希望帮到你。本文还有配套的精品资源点击获取