SAM模型C++部署实战:基于ONNX与OpenVINO的全流程解析
简介一份基于ONNX、OpenVINO与C的SAM分割万物模型部署实战项目面向算法部署工程师、C开发者和AI应用落地人员解决从模型导出到高性能推理上线的完整链路问题。压缩包共23个文件涵盖Python模型导出/转换脚本、C推理源码与头文件、Markdown流程教程、TXT说明文档以及示例测试图片整体仅2.22MB目录按cpp、python、docs等模块划分便于对照学习。资源上线后已有220人学习下载是算法部署方向参考价值明确的小而精案例。项目并非只给代码而是以一份可操作的实战教程贯穿始终从环境准备、依赖安装、ONNX格式导出、OpenVINO模型优化与转换到用C编写应用加载模型、完成基于点或框提示的图像分割均有分步说明与示例支撑。对希望快速掌握SAM本地化部署、或将Python原型改写成C视觉服务的开发者而言这是一份低门槛、可直接改造的参考实现。1. 把 SAM 部署到 C 工程ONNX OpenVINO 到底解决什么问题很多人拿到 SAMSegment Anything Model的第一反应是在 Python 里跑model.predict()但真实业务场景里模型最终要跑在 C 服务里要能脱离 PyTorch 环境独立运行还要把推理延迟压到可接受范围。这个项目就是一套完整的部署链路先把 SAM 从 PyTorch 导出成 ONNX再用 OpenVINO 做模型优化和推理引擎最后用 C 封装成可调用的分割服务。它适合两类人一类是算法工程师想把研究用的 SAM 落地成实际接口另一类是 C 开发想搞清楚深度学习模型的加载、推理、输出解析到底怎么和业务代码衔接。项目里既有 Python 侧的导出脚本也有 C 侧的 CMake 工程和推理实现还附了一张测试图和一份流程文档照着走一遍基本能把整条链路跑通。这里不讨论 SAM 本身的原理有多深重点在部署路径上的每个坑怎么填。2. 模型转换链从 PyTorch 到 ONNX先让 SAM 走出训练框架2.1 SAM 的模型结构决定了导出方式SAM 不是单一模型它由 Image Encoder图像编码器、Prompt Encoder提示编码器、Mask Decoder掩码解码器三部分组成。部署时大多数场景只用 Image Encoder Mask DecoderPrompt Encoder 通常只在交互式分割时需要动态构造输入。这个项目的导出脚本export_model.py把重点放在 Image Encoder 和 Mask Decoder 的导出上因为 C 应用里最常用的方式是给定一张图和若干个点或框输出对应的分割掩码。先看项目里python目录下的文件组织python/ ├── export_model.py # PyTorch - ONNX 导出脚本 ├── ONNXSam.py # 基于 ONNX Runtime 的 Python 推理封装 ├── run_original_sam.py # 跑原始 SAM 做对比验证 ├── utils.py # 图像预处理、可视化辅助 └── requirements.txt这种拆分很合理。run_original_sam.py是基准用来验证原始 PyTorch 模型的输出export_model.py负责导出ONNXSam.py是轻量级验证确保导出的 ONNX 模型在 Python 侧推理结果和原始模型一致。这个「先原版、再导出、再验证」的顺序在部署里非常重要能帮你快速定位是转换出了问题还是下游代码出了问题。2.2 导出脚本的关键参数与踩坑点导出脚本的核心逻辑是加载原始 SAM 权重把 Image Encoder 和 Mask Decoder 拆开分别用torch.onnx.export导出。这里最容易出问题的地方在dynamic_axes的设置。如果你只导出一个固定尺寸的模型比如 1024×1024 输入C 端就被锁死了换一张不同分辨率的图要么 resize 变形要么重新导出。项目里对 Image Encoder 的输入设置了动态轴# export_model.py 核心逻辑 import torch from segment_anything import sam_model_registry sam sam_model_registry[vit_h](checkpointsam_vit_h_4b8939.pth) sam.eval() # 导出 Image Encoder image_encoder sam.image_encoder dummy_input torch.randn(1, 3, 1024, 1024) torch.onnx.export( image_encoder, dummy_input, sam_image_encoder.onnx, opset_version11, input_names[input_image], output_names[image_embeddings], dynamic_axes{ input_image: {0: batch_size}, image_embeddings: {0: batch_size}, }, )这里把batch_size设成动态height和width保持固定。原因是 Image Encoder 内部有位置编码positional encoding位置编码的 shape 和输入分辨率是绑定的如果高度宽度也设成动态位置编码需要插值容易引入精度损失。所以常见做法是固定 1024×1024 输入只让 batch 维度可变。如果你确实需要多分辨率输入要在预处理里做 resize 到 1024×1024而不是让模型去适配任意尺寸。Mask Decoder 的导出稍微复杂它的输入包括image_embeddings来自 Image Encoder 的输出、point_coords点坐标、point_labels点标签、mask_input上一次的掩码用于迭代优化和has_mask_input标志位。导出时需要把这些输入全部对齐# Mask Decoder 导出关键代码 mask_decoder sam.mask_decoder dummy_embeddings torch.randn(1, 256, 64, 64) # 和 Image Encoder 输出匹配 dummy_points torch.randn(1, 2, 2) # 两个点每个点 x,y 坐标 dummy_labels torch.tensor([[1, 0]], dtypetorch.float32) # 1 表示前景点0 表示背景点 dummy_mask_input torch.randn(1, 1, 256, 256) # 迭代掩码输入 dummy_has_mask torch.tensor([[0.0]]) # 0 表示没有提供掩码 torch.onnx.export( mask_decoder, (dummy_embeddings, dummy_points, dummy_labels, dummy_mask_input, dummy_has_mask), sam_mask_decoder.onnx, opset_version11, input_names[image_embeddings, point_coords, point_labels, mask_input, has_mask_input], output_names[masks, iou_predictions, low_res_masks], )注意point_coords的 shape 是[batch, num_points, 2]这里 num_points 用了 2但实际部署时点的数量可能变化。如果想让点数动态需要把point_coords和point_labels的第 1 维也设成动态。但有个隐患ONNX 图里如果点数的动态维度过大OpenVINO 转换时可能会产生额外的运行时开销。我一般建议固定点数比如交互式分割每次固定输入 3 个点不足的补零靠point_labels里的 -1 标记无效点。2.3 验证转换结果是否一致导出完成后别急着进下一步先用ONNXSam.py在 Python 侧对比 ONNX 模型和 PyTorch 原模型的输出差异。这个项目里对比方法很简单同一个输入分别跑原版和 ONNX 版算分割掩码的 IoU。IoU 在 0.98 以上基本可以认为转换无损如果低于这个值优先检查opset_version是不是太低以及位置编码有没有被错误地固定。common 的做法是先在 Python 侧把精度问题排查干净再进 C因为 C 侧调起模型来不像 Python 那么方便。3. OpenVINO 转换与优化让模型在 CPU 上跑出接近实时的速度3.1 为什么用 OpenVINO 而不是直接 ONNX RuntimeONNX 是中间格式ONNX Runtime 可以直接跑但 OpenVINO 在 Intel CPU 上的优化明显更好。它会把 ONNX 模型做层融合、内存复用、指令集优化特别是对卷积和矩阵乘这类算子能自动选择最优的 kernel。这个项目选 OpenVINO 的核心原因是「免费的性能提升」——不需要改任何模型结构只靠转换工具就能获得推理速度上的收益。先把 Python 环境准备好项目里给了requirements.txt核心是这几项pip install openvino2023.3.0 pip install openvino-dev2023.3.0 pip install onnx pip install onnxruntime pip install segment-anything pip install opencv-python pip install torch torchvisionopenvino-dev里带了 Model Optimizer 工具mo命令用于把 ONNX 转成 OpenVINO 的 IR 格式。转出来的文件有两个.xml模型结构和.bin权重。C 端加载时实际上用的是model core.read_model(model.xml)OpenVINO 会同时读取同名的.bin文件。3.2 ONNX 转 IR 的命令与参数说明在项目根目录的docs文件夹里作者写了一个转换流程文档实际到命令行就是下面这两步mkdir -p ir_model mo \ --input_model sam_image_encoder.onnx \ --output_dir ir_model \ --input_shape [1,3,1024,1024] \ --data_type FP32 \ --model_name sam_image_encoder mo \ --input_model sam_mask_decoder.onnx \ --output_dir ir_model \ --data_type FP32 \ --model_name sam_mask_decoder--input_shape显式指定输入尺寸避免动态 batch 带来额外的转换复杂度--data_type FP32是稳妥选项如果你的部署环境对性能要求更高可以试试FP16在部分 CPU 和 GPU 上能换接近一倍的推理速度提升但精度会有一丁点损失。--model_name决定输出的.xml和.bin文件叫什么名字。如果你的环境不支持 GPU纯 CPU 部署可以在mo命令里加一个--compress_fp16选项把权重压缩到 FP16模型体积减半推理速度略有提升精度影响通常可以忽略。这个我在实际项目里经常用对分割类模型尤其安全。3.3 量化到 int8什么时候值得做热词里有人搜「onnx量化int8」这里顺带说清楚。OpenVINO 支持把模型量化到 int8有三种方式训练后量化Post-training QuantizationPTQ、量化感知训练QAT、运行时动态量化。这个项目没提供量化脚本但如果你想在低端 CPU 上跑PTQ 是最快见效的路径。用 OpenVINO 的 API 做 PTQ 的基本流程是import openvino as ov from openvino.tools.pot import DataLoader, IEEngine, save_model from openvino.tools.pot.engines.ie_engine import IEEngine as POTIEEngine # 使用校准数据集 engine POTIEEngine(config{}, modelmodel, data_loaderdata_loader) quantized_model engine.quantize()校准数据集的选择直接决定量化精度。一般建议选 100~500 张覆盖典型场景的图不能只拿一张测试图去校准。对 SAM 这种分割模型量化后 IoU 可能下降 1~3 个百分点如果你的业务对分割边界要求很苛刻比如医学影像或工业质检建议先做 PTQ 评估再决定要不要上。4. C 推理工程从 ONNX 模型到可调用的分割接口4.1 工程结构与 CMake 配置cpp目录下是这个项目最核心的 C 工程先看结构cpp/ ├── CMakeLists.txt ├── vino_executor/ # OpenVINO 推理封装层 │ ├── vino_executor.h │ └── vino_executor.cpp ├── cppsam/ # SAM 模型封装 │ ├── cppsam.h │ └── cppsam.cpp └── test_app/ # 测试入口 └── main.cppvino_executor负责对 OpenVINO API 的封装包括模型加载、输入张量分配、推理执行、输出张量解析cppsam在这之上封装 SAM 完整推理流程test_app是最终的可执行程序接收图片路径和提示点坐标输出分割掩码。CMakeLists.txt 的关键是找到 OpenVINO 和 OpenCVcmake_minimum_required(VERSION 3.16) project(cppsam_deploy) set(CMAKE_CXX_STANDARD 17) find_package(OpenVINO REQUIRED) find_package(OpenCV REQUIRED) include_directories(${OpenVINO_INCLUDE_DIRS} ${OpenCV_INCLUDE_DIRS}) add_executable(test_app test_app/main.cpp vino_executor/vino_executor.cpp cppsam/cppsam.cpp ) target_link_libraries(test_app openvino::runtime ${OpenCV_LIBS} )openvino::runtime是 OpenVINO 2023 之后推荐的链接方式旧版本用openvino::inference_engine。如果你的环境是 2022 以前的版本链接名要改。4.2 vino_executor 的实现要点vino_executor.cpp里最核心的一段是模型加载和推理调用#include openvino/openvino.hpp #include opencv2/opencv.hpp class VinoExecutor { public: VinoExecutor(const std::string model_path, const std::string device_name CPU) { // 1. 创建 Core 对象这个相当于 OpenVINO 的「总入口」 ov::Core core; // 2. 读取模型.xml 是结构同名 .bin 会自动加载 model_ core.read_model(model_path); // 3. 编译模型到指定设备这一步相当于「预处理 编译」 compiled_model_ core.compile_model(model_, device_name); // 4. 创建推理请求对象 infer_request_ compiled_model_.create_infer_request(); } ov::Tensor run(const cv::Mat input_image) { // 1. 获取输入张量这里是获取模型要求的输入尺寸 auto input_shape compiled_model_.input().get_shape(); int batch_size input_shape[0]; int channels input_shape[1]; int height input_shape[2]; int width input_shape[3]; // 2. 创建 OpenVINO 张量注意数据布局是 NCHW ov::Tensor input_tensor(ov::element::f32, {batch_size, channels, height, width}); // 3. 把 cv::Mat 的数据拷贝到输入张量中 // cv::Mat 是 HWC需要转成 CHW float* input_data input_tensor.datafloat(); for (int c 0; c channels; c) { for (int h 0; h height; h) { for (int w 0; w width; w) { input_data[c * height * width h * width w] input_image.atcv::Vec3f(h, w)[c]; } } } // 4. 执行推理 infer_request_.set_input_tensor(input_tensor); infer_request_.infer(); // 5. 获取输出张量 auto output_tensor infer_request_.get_output_tensor(); return output_tensor; } private: ov::CompiledModel compiled_model_; ov::InferRequest infer_request_; ov::Model model_; };这段代码有两点需要注意。第一ov::Core创建后可以复用不要每次推理都创建这是性能杀手。第二cv::Mat到 OpenVINO 张量的数据拷贝用了最直接的三层循环虽然不够优雅但最容易理解。实际工程里可以用cv::dnn::blobFromImage一步完成缩放和 HWC 到 CHW 的转换但它的归一化方式和 SAM 的预处理有个细节SAM 要求输入图像的像素值除以 255 再归一化到 ImageNet 的 mean/std而blobFromImage的mean和scale参数刚好能对齐只是要注意顺序。4.3 cppsam 的推理流程组织cppsam.cpp把 Image Encoder 和 Mask Decoder 串了起来完整流程是cv::Mat CppSam::segment(const cv::Mat image, const std::vectorcv::Point2f points) { // 1. 图像预处理resize 到 1024x1024归一化 cv::Mat resized, float_img, normalized; cv::resize(image, resized, cv::Size(1024, 1024)); resized.convertTo(float_img, CV_32FC3, 1.0 / 255.0); // ImageNet 归一化 static const float mean[] {0.485, 0.456, 0.406}; static const float std[] {0.229, 0.224, 0.225}; for (int c 0; c 3; c) { cv::extractChannel(float_img, normalized, c); normalized - mean[c]; normalized / std[c]; } // 2. 执行 Image Encoder ov::Tensor embeddings encoder_.run(normalized); // 3. 构造 Mask Decoder 输入 // 注意Prompt 点的坐标要映射到 1024x1024 的坐标系 std::vectorfloat points_data; for (const auto p : points) { points_data.push_back(p.x * 1024.0f / image.cols); points_data.push_back(p.y * 1024.0f / image.rows); } // 4. 执行 Mask Decoder ov::Tensor masks decoder_.run(embeddings, points_data); // 5. 将输出从 256x256 resize 回原图尺寸 float* mask_data masks.datafloat(); cv::Mat low_res(256, 256, CV_32FC1, mask_data); cv::Mat full_res; cv::resize(low_res, full_res, cv::Size(image.cols, image.rows)); return full_res 0.0f; // 阈值化得到二值掩码 }需要特别注意「Prompt 点坐标映射」这一步。原始 SAM 在训练时输入坐标是基于 1024×1024 的编码器输入分辨率而用户在界面上点击的坐标是原始图像分辨率。如果不做坐标换算分割结果会严重偏移。换算方法很简单点击坐标乘以 1024 再除以原图宽/高得到编码器坐标系下的坐标再喂给 Mask Decoder。这个坑我在第一次上手 SAM 部署时踩过输出的掩码完全不对后来逐行打印输入输出才定位到是坐标没有映射。另一个细节是 Mask Decoder 里的has_mask_input和mask_input。首次推理时mask_input传全零张量has_mask_input传 0后续如果你想用上一次的预测做迭代细化可以把上一次的输出 resize 到 256×256 作为输入。这个项目里没有做迭代但留下了接口位置你可以在cppsam.cpp里扩展。5. 部署避坑记录五个最容易翻车的位置5.1 现象模型加载报错提示无法读取 .xml 文件原因OpenVINO 的read_model读取的路径不对。很多人把.xml和.bin放在了不同目录或者只传了.xml没注意同名.bin是否存在。解决让.xml和.bin在同一目录且文件名一致加载时用绝对路径不要用相对路径。C 里推荐把路径作为命令行参数传入不要写死在代码里。另外确认core.read_model之后要core.compile_model这两个步骤不能合并也不能省略。5.2 现象推理输出全是黑色或全是白色的掩码原因输出张量的解析方式不对。Mask Decoder 的输出有三个分别是masks、iou_predictions、low_res_masks。注意这个项目的输出顺序第一个masks已经是 sigmoid 之后的值范围在 0~1 之间如果你直接拿原始 logits 做阈值化可能所有值都小于 0二值化后全黑。解决打印输出的min和max值如果范围是 0~1 直接阈值化如果是负数则先过 sigmoid。C 里对每个元素做1.0f / (1.0f std::exp(-x))即可。另外注意 OpenVINO 的输出张量是 float32直接用datafloat()解析没问题。5.3 现象分割结果位置有偏移物体轮廓是对的但位置不对原因Prompt 点的坐标没有映射回编码器输入分辨率。原图 1920×1080编码器输入 1024×1024如果用户的点击坐标直接用原图坐标模型会把坐标当成 1024 坐标系里的值位置就完全错位了。解决按上面说的坐标换算公式处理。做一个辅助函数cv::Point2f map_to_encoder(const cv::Point2f point, const cv::Size original_size) { return { point.x * 1024.0f / original_size.width, point.y * 1024.0f / original_size.height }; }这个函数建议写进 cppsam 的工具函数里每次调用都强制走一遍不要依赖调用方记得换算。5.4 现象推理速度慢C 端一次推理要好几秒原因有几种可能。第一种是每次推理都创建了ov::Core和CompiledModel这个开销非常大第二种是 OpenVINO 在 CPU 上没有启用多线程第三种是模型是 FP32 且没有做任何优化。解决ov::Core、CompiledModel、InferRequest三者都在应用初始化时创建整个生命周期内复用。可以在编译模型时设置性能提示ov::hint::PerformanceMode perf_mode ov::hint::PerformanceMode::THROUGHPUT; core.set_property(ov::hint::performance_mode(perf_mode));也可以用ov::inference_num_threads(4)指定线程数。注意线程数不是越大越好通常设为物理核心数。另外如果用的是 11 代以后的 Intel CPU可以尝试给compile_model传入GPU设备部分核显的性能比 CPU 好很多。5.5 现象编译工程时报找不到 OpenVINOConfig.cmake原因OpenVINO 的 CMake 配置没有在CMAKE_PREFIX_PATH中或者安装的是 Python 版本而不是 C 版本。有人只pip install openvino就来找 C 的头文件肯定找不到。解决C 部署需要单独安装 OpenVINO Runtime C 库推荐用以下方式之一# Ubuntu / Debian 系 sudo apt install openvino-runtime # 或者从官网下载 OpenVINO Runtime 的 Linux 压缩包解压后设置环境变量 source /opt/intel/openvino_2023/setupvars.sh安装后把安装路径加到CMAKE_PREFIX_PATH再重新 cmake。如果用的是 vcpkgvcpkg install openvino也可以。6. 进阶把单图推理改成批量异步处理并做一次完整的精度验证单张图推理是入门但实际业务里很少只处理一张图。这里给你一个改进方向异步批量推理。OpenVINO 的InferRequest支持异步调用可以在等待当前推理结果的同时预处理下一张图把 CPU 的空闲时间压下来。// 异步推理的基本思路 ov::InferRequest request compiled_model.create_infer_request(); // 启动异步推理 request.set_input_tensor(input_tensor); request.start_async(); // 处理其他任务或者预处理下一张图 cv::Mat next_image preprocess(next_frame); // 等推理完成 request.wait(); ov::Tensor output request.get_output_tensor();关键点是一个InferRequest一次只能跑一个任务多个请求需要创建多个InferRequest对象OpenVINO 内部会自动做请求调度。我一般会建一个 4~8 个请求的池子每个请求绑定一个线程配合THROUGHPUT模式吞吐量能提升 3~5 倍。另一个值得做的是精度验证。项目里的run_original_sam.py可以跑出原始 SAM 的输出把它存成png然后在 C 端跑同样的输入也用png保存输出用 OpenCV 计算两个掩码的 IoU。推荐的验证方式是用一个脚本遍历测试集# 在 data 目录下准备一组测试图片和对应的 prompt 点 ./test_app /path/to/image.jpg /path/to/points.txt /path/to/output.png然后对比output.png和original_sam_output.png。IoU 低于 0.95 就要回头排查是导出阶段还是 C 预处理阶段出了问题。通常预处理阶段的归一化最容易漏比如忘了除以 255或者 BGR 和 RGB 通道顺序搞反这会导致掩码边界偏一点但整体轮廓能看出来的现象。最后说一个我自己的习惯在这个项目基础上做二次开发时我会把vino_executor做成单例把cppsam的实例放到服务启动时初始化每次请求只调用segment()方法同时会把 prompt 点的坐标换算放到 C 层的最前面而不是依赖调用方传给定好的坐标每次发布前强制跑一遍原版和 ONNX 版的 IoU 对比脚本确保改动没有引入精度退化。这套流程帮我在几个实际项目里少踩了不少坑希望帮到你。本文还有配套的精品资源点击获取