PyTorch到C++:SAM模型ONNX导出与OpenVINO INT8量化部署实战
发布时间:2026/10/3 2:50:37 作者:尧图编辑部 阅读量:1,286

简介这份资源面向希望将深度学习模型落地到实际工程中的算法工程师与C开发者聚焦SAM分割万物模型在ONNX、OpenVINO与C技术栈下的完整部署方案帮助读者打通从模型导出、格式转换到高性能推理的落地链路。压缩包共23个文件约2.22MB涵盖C源码与头文件、Python导出与推理脚本、CMake构建配置、说明文档及测试图片等结构清晰便于按模块查阅与二次开发。目前已有221人学习。资源提供可直接运行的工程源码与分步流程教程覆盖环境准备、依赖安装、模型转换与示例程序运行等环节并配有测试图像与说明文档方便读者对照验证分割效果、理解推理引擎调用方式快速将高性能图像分割能力集成到自身项目中。1. 从 PyTorch 到 C 推理这套 SAM 部署方案到底解决了什么如果你试过把 SAMSegment Anything Model塞进一个 C 工程大概率经历过这样的场景PyTorch 里跑得好好的模型一到部署环节就卡在环境依赖、推理速度、内存占用这三座大山面前。Python 端torch加载 SAM 动辄几个 G 的显存占用推理一张图要等好几秒放到实际产品里根本没法用。这个项目做的事情很直接——把 SAM 的编码器和解码器拆开编码器导出为 ONNX再用 OpenVINO 做图优化和 INT8 量化最后用 C 写一个推理执行器在 CPU 上把单张图的分割延迟压到可接受的范围。它适合两类人一类是已经跑通了 SAM 的 Python demo想把它落到 C 服务或边缘设备上的工程师另一类是正在学 ONNX 和 OpenVINO 部署链路需要一个完整可复现案例的开发者。项目包里给了export_model.py、ONNXSam.py、vino_executor、cppsam这些关键文件还有test_image.jpg和original.png用来验证效果不是那种只丢一个模型文件让你自己猜的“半成品”。2. 模型导出与 ONNX 图拆解SAM 的两个模块怎么分开跑2.1 为什么 SAM 不能直接整图导出SAM 的结构和普通分类、检测模型不一样。它由三部分组成Image Encoder通常是 ViT-H/L/B、Prompt Encoder、Mask Decoder。Image Encoder 负责把输入图像编码成一个高维特征图Prompt Encoder 处理点、框、掩码这类提示信息Mask Decoder 再结合两者输出分割结果。问题在于Image Encoder 的计算量极大一张 1024×1024 的图在 ViT-H 上跑一次要几百毫秒甚至更久而 Prompt Encoder 和 Mask Decoder 非常轻量参数量不到编码器的百分之一。如果你把整个 SAM 当成一个 ONNX 模型导出每次换一个提示点就要重新跑一遍 Image Encoder这在交互式分割场景里完全不可接受。常见做法是把 Image Encoder 单独导出成一个 ONNXPrompt Encoder 和 Mask Decoder 合并导出成另一个 ONNX。图像特征只算一次后续用户点多少个点、画多少个框都只跑第二个小模型。项目里的export_model.py就是干这个的它会把 SAM 的 checkpoint 加载进来分别 trace 两个子模块然后调torch.onnx.export输出两个.onnx文件。2.2 导出脚本的关键参数与实操先看export_model.py里最核心的导出逻辑。下面这段代码是编码器导出的简化版实际项目里会多一层封装但参数含义是一样的import torch from segment_anything import sam_model_registry # 加载原始 SAM checkpoint这里以 vit_b 为例 sam sam_model_registry[vit_b](checkpointsam_vit_b_01ec64.pth) sam.eval() # 构造一个符合导出要求的输入张量 # SAM 的 Image Encoder 固定输入 1024x1024归一化参数在预处理里做 dummy_input torch.randn(1, 3, 1024, 1024) # 只导出 image_encoder 子模块 torch.onnx.export( sam.image_encoder, # 要导出的模块 dummy_input, # 示例输入 sam_image_encoder.onnx, # 输出文件名 input_names[input_image],# 输入节点名C 侧按这个名字找 output_names[image_embed],# 输出节点名 opset_version17, # 算子集版本OpenVINO 对 17 支持较好 do_constant_foldingTrue, # 常量折叠减小图体积 dynamic_axesNone # 编码器输入尺寸固定不需要动态轴 )这段代码的逻辑很清晰加载模型、构造 dummy input、调导出接口。参数上最需要注意的是opset_version。OpenVINO 的模型优化器对 ONNX opset 的支持是分版本的opset 17 在 2023 以后的 OpenVINO 版本里支持比较完整如果你用 opset 18 或更高可能会遇到某些算子无法映射的问题。dynamic_axes这里设为None因为 Image Encoder 的输入尺寸是固定的 1024×1024不需要动态 batch 或动态分辨率。如果你确实需要动态 batch可以设成{input_image: {0: batch_size}}但 OpenVINO 在动态 shape 下的性能会打折扣能固定就固定。Prompt Encoder 和 Mask Decoder 的导出稍微麻烦一点因为它们的输入不止一个。下面是对应代码# 导出 prompt_encoder mask_decoder 组合 # 注意SAM 原始实现里这两个模块是分开的需要包一层 class SamPromptDecoder(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_embed, point_coords, point_labels): # sparse_embeddings 和 dense_embeddings 来自 prompt_encoder sparse_emb, dense_emb self.prompt_encoder( points(point_coords, point_labels), boxesNone, masksNone ) # mask_decoder 输出低分辨率掩码和 IoU 预测 low_res_masks, iou_predictions self.mask_decoder( image_embeddingsimage_embed, image_peself.prompt_encoder.get_dense_pe(), sparse_prompt_embeddingssparse_emb, dense_prompt_embeddingsdense_emb, multimask_outputTrue ) return low_res_masks, iou_predictions wrapper SamPromptDecoder(sam) wrapper.eval() dummy_image_embed torch.randn(1, 256, 64, 64) # vit_b 的特征图尺寸 dummy_point_coords torch.tensor([[[100.0, 200.0]]]) # 一个点 dummy_point_labels torch.tensor([[1]]) # 1 表示前景点 torch.onnx.export( wrapper, (dummy_image_embed, dummy_point_coords, dummy_point_labels), sam_prompt_decoder.onnx, input_names[image_embed, point_coords, point_labels], output_names[low_res_masks, iou_predictions], opset_version17, dynamic_axes{ point_coords: {1: num_points}, # 点数可变 point_labels: {1: num_points} } )这里有几个容易翻车的点。第一image_embed的尺寸取决于你用的 ViT 版本vit_b 是 256×64×64vit_l 和 vit_h 是 256×64×64 但通道数不同导出前一定要确认清楚否则 C 侧喂进去的数据对不上。第二point_coords的坐标是归一化到 1024×1024 尺度下的浮点数不是原始图像像素坐标预处理阶段要做缩放。第三multimask_outputTrue会输出 3 个候选掩码C 侧需要根据iou_predictions选最高的那个或者根据业务逻辑做后处理。项目里的ONNXSam.py对这套流程做了封装可以直接参考它的输入输出处理方式。2.3 导出后的自检清单导出完成之后别急着往 OpenVINO 里塞先用onnxruntime跑一遍 Python 推理确认输出和原始 PyTorch 一致。常见做法是拿test_image.jpg做一次前向对比low_res_masks的数值差异如果 max diff 在 1e-3 以内就算正常。如果差异过大优先检查opset_version和do_constant_folding这两个参数。另外用 Netron 打开导出的 ONNX 文件看一眼输入输出节点名和维度是否和预期一致这一步能省掉后面很多调试时间。3. OpenVINO 模型转换与 INT8 量化把推理速度压下来3.1 用 mo 把 ONNX 转成 IR 格式ONNX 只是中间格式OpenVINO 真正跑推理用的是 IRIntermediate Representation包含.xml和.bin两个文件。转换命令在项目docs目录下应该有记录核心就是调mo这个命令行工具# 转换 Image Encoder mo --input_model sam_image_encoder.onnx \ --output_dir ./ir_model \ --input_shape [1,3,1024,1024] \ --mean_values [123.675,116.28,103.53] \ --scale_values [58.395,57.12,57.375] \ --data_type FP16 # 转换 Prompt Decoder mo --input_model sam_prompt_decoder.onnx \ --output_dir ./ir_model \ --input image_embed,point_coords,point_labels \ --data_type FP16--mean_values和--scale_values是 SAM 预处理的标准参数来自 ImageNet 的归一化统计量。如果你在 Python 导出阶段已经把归一化做进了模型里这里就不用重复设置否则会出现两次归一化导致结果完全错误。--data_type FP16在支持 FP16 的硬件上能进一步提速但如果你的 CPU 比较老不支持 AVX-512 FP16 指令集建议先用 FP32 跑通再尝试 FP16。转换完成后IR 模型的文件名默认和 ONNX 同名只是后缀变成.xml和.bin。3.2 INT8 量化的正确姿势FP16 只是开胃菜真正能把 CPU 推理速度拉上来的是 INT8 量化。OpenVINO 的 POTPost-training Optimization Tool支持不需要重新训练的训练后量化但需要一份校准数据集。项目里没有附带校准集我一般会从test_image.jpg里裁几十张 1024×1024 的 patch 作为校准数据。下面是一个典型的 POT 配置from openvino.tools.pot import DataLoader, IEEngine, load_model, save_model, compress_model_weights from openvino.tools.pot.graph import load_model from openvino.tools.pot.pipeline.initializer import create_pipeline # 自定义 DataLoader返回校准用的图像 class SamCalibDataLoader(DataLoader): def __init__(self, image_paths): self.image_paths image_paths def __len__(self): return len(self.image_paths) def __getitem__(self, index): # 这里省略具体的图像读取和预处理 # 返回格式必须是 dictkey 对应模型输入名 image preprocess(self.image_paths[index]) return {input_image: image} # 加载 FP32 IR 模型 model load_model(./ir_model/sam_image_encoder.xml) # 配置量化算法 algorithms [{ name: DefaultQuantization, params: { target_device: CPU, preset: performance, stat_subset_size: 300 } }] pipeline create_pipeline(algorithms, IEEngine) calib_loader SamCalibDataLoader(calib_images) compressed_model pipeline.run(model, calib_loader) save_model(compressed_model, ./ir_model_int8/sam_image_encoder)stat_subset_size控制校准用的样本数量300 是一个比较稳妥的值太少会导致量化参数估计不准太多则校准时间过长。preset选performance会优先保证速度选accuracy则会在量化时保留更多精度。量化完成后用benchmark_app对比一下 FP32 和 INT8 的推理延迟benchmark_app -m ./ir_model/sam_image_encoder.xml -d CPU -api async -niter 50 benchmark_app -m ./ir_model_int8/sam_image_encoder.xml -d CPU -api async -niter 50在常见的 i7 或 Xeon 上INT8 相比 FP32 通常有 2 到 3 倍的吞吐提升但分割掩码的边缘可能会略微粗糙。如果业务对边缘精度敏感可以只对 Image Encoder 做 INT8Prompt Decoder 保持 FP16这样能在速度和精度之间取一个平衡。3.3 转换过程中的版本匹配问题OpenVINO 的版本和 ONNX opset 之间有严格的对应关系。如果你用的 OpenVINO 是 2023.0 之前的版本opset 17 里的某些算子可能不被支持转换时会报Unsupported operation错误。解决办法有两个要么升级 OpenVINO 到 2023.1 或更高要么在导出 ONNX 时把opset_version降到 15 或 16。项目 README 里应该写了推荐的 OpenVINO 版本照着装能省不少事。另外mo工具在 OpenVINO 2024 之后改成了ovc命令参数基本一致只是命令名变了如果你照着老教程敲mo发现找不到命令换成ovc试试。4. C 推理执行器从加载 IR 到输出掩码的完整链路4.1 CMakeLists 里的依赖配置项目的cpp目录下有CMakeLists.txt这是整个 C 工程的构建入口。OpenVINO 的 C API 依赖几个核心库openvino::runtime、openvino::runtime::dev如果要用预处理 API 还需要openvino::runtime::preprocess。下面是一个精简版的 CMakeLists 关键片段cmake_minimum_required(VERSION 3.10) project(cppsam) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 查找 OpenVINO需要先 source setupvars.sh find_package(OpenVINO REQUIRED) add_executable(cppsam main.cpp vino_executor.cpp ) target_link_libraries(cppsam PRIVATE openvino::runtime openvino::runtime::dev ) # 如果要用 OpenCV 做图像读写和可视化 find_package(OpenCV REQUIRED) target_link_libraries(cppsam PRIVATE ${OpenCV_LIBS})find_package(OpenVINO REQUIRED)能工作的前提是你已经执行过 OpenVINO 安装目录下的setupvars.sh这个脚本会设置OpenVINO_DIR环境变量。如果你在 Windows 上用 Visual Studio对应的就是setupvars.bat。很多新手在这一步翻车CMake 报Could not find a package configuration file provided by OpenVINO九成是因为没 source 环境变量。4.2 加载模型与创建推理请求vino_executor.cpp里封装了模型加载和推理的核心逻辑。下面这段代码展示了如何加载 IR 模型并创建推理请求#include openvino/openvino.hpp #include opencv2/opencv.hpp class VinoExecutor { public: VinoExecutor(const std::string model_path, const std::string device CPU) { // 读取 IR 模型model_path 不带后缀会自动找 .xml 和 .bin auto model core_.read_model(model_path .xml); // 编译模型到指定设备 compiled_model_ core_.compile_model(model, device); // 创建推理请求SAM 编码器用单请求即可 infer_request_ compiled_model_.create_infer_request(); } cv::Mat inferImageEncoder(const cv::Mat input_image) { // 获取输入端口信息 auto input_port compiled_model_.input(); ov::Shape input_shape input_port.get_shape(); // 用 OpenVINO 预处理 API 做 resize 和归一化 ov::preprocess::PrePostProcessor ppp(compiled_model_); ppp.input() .tensor() .set_element_type(ov::element::u8) .set_layout(NHWC) .set_shape({1, input_image.rows, input_image.cols, 3}); ppp.input().preprocess() .resize(ov::preprocess::ResizeAlgorithm::RESIZE_LINEAR, 1024, 1024) .convert_element_type(ov::element::f32) .mean({123.675f, 116.28f, 103.53f}) .scale({58.395f, 57.12f, 57.375f}); ppp.input().model().set_layout(NCHW); compiled_model_ ppp.build(); // 填充输入数据 ov::Tensor input_tensor(input_port.get_element_type(), input_shape, input_image.data); infer_request_.set_input_tensor(input_tensor); // 同步推理 infer_request_.infer(); // 获取输出特征图 auto output_tensor infer_request_.get_output_tensor(); ov::Shape output_shape output_tensor.get_shape(); // 把输出拷贝到 cv::Mat方便后续传给 decoder // 这里假设输出是 1x256x64x64 cv::Mat image_embed(64, 64, CV_32FC(256)); std::memcpy(image_embed.data, output_tensor.datafloat(), output_tensor.get_byte_size()); return image_embed; } private: ov::Core core_; ov::CompiledModel compiled_model_; ov::InferRequest infer_request_; };这段代码有几个关键点。第一core_.read_model只需要传.xml的路径OpenVINO 会自动关联同名的.bin文件。第二预处理 API 的mean和scale参数必须和 Python 导出时的归一化参数一致否则输入分布对不上输出全是噪声。第三ov::Tensor构造时直接引用了input_image.data这意味着input_image的生命周期必须覆盖整个推理过程如果你在异步推理里用这种方式要确保图像数据不会被提前释放。第四输出特征图的拷贝方式取决于实际输出维度vit_b 是 1×256×64×64vit_h 是 1×256×64×64 但通道数不同用之前先用output_shape打印确认一下。4.3 Prompt Decoder 的 C 调用与掩码后处理编码器输出特征图之后Prompt Decoder 的调用相对轻量但输入构造更琐碎。下面是对应的 C 代码cv::Mat inferPromptDecoder(const cv::Mat image_embed, const std::vectorcv::Point2f points, const std::vectorint labels) { // 构造 point_coords 输入形状 [1, N, 2] ov::Tensor coords_tensor(ov::element::f32, {1, points.size(), 2}); float* coords_data coords_tensor.datafloat(); for (size_t i 0; i points.size(); i) { coords_data[i * 2] points[i].x; coords_data[i * 2 1] points[i].y; } // 构造 point_labels 输入形状 [1, N] ov::Tensor labels_tensor(ov::element::i32, {1, labels.size()}); std::memcpy(labels_tensor.dataint(), labels.data(), labels.size() * sizeof(int)); // 构造 image_embed 输入 ov::Tensor embed_tensor(ov::element::f32, {1, 256, 64, 64}, image_embed.data); // 设置输入并推理 decoder_request_.set_input_tensor(0, embed_tensor); decoder_request_.set_input_tensor(1, coords_tensor); decoder_request_.set_input_tensor(2, labels_tensor); decoder_request_.infer(); // 获取输出low_res_masks [1, 3, 256, 256] 和 iou_predictions [1, 3] auto masks_tensor decoder_request_.get_output_tensor(0); auto iou_tensor decoder_request_.get_output_tensor(1); // 选 IoU 最高的那个掩码 float* iou_data iou_tensor.datafloat(); int best_idx std::max_element(iou_data, iou_data 3) - iou_data; // 提取对应的掩码并做后处理 float* masks_data masks_tensor.datafloat(); int mask_offset best_idx * 256 * 256; cv::Mat low_res_mask(256, 256, CV_32FC1, masks_data mask_offset); // 二值化 resize 回原图尺寸 cv::Mat binary_mask; cv::threshold(low_res_mask, binary_mask, 0.0, 255.0, cv::THRESH_BINARY); binary_mask.convertTo(binary_mask, CV_8UC1); return binary_mask; }point_coords的坐标必须是归一化到 1024×1024 尺度下的值如果你从原图上取的点要先乘以1024.0 / original_width和1024.0 / original_height。point_labels里 1 表示前景点0 表示背景点-1 表示 padding。low_res_masks的输出是 256×256 的低分辨率掩码需要 resize 回原图尺寸再做二值化。后处理里 threshold 的阈值选 0.0 是因为 SAM 输出的 logits 已经过了 sigmoid0.0 对应概率 0.5。如果你发现掩码边缘有锯齿可以用双线性插值代替最近邻插值但速度会慢一点。4.4 完整推理流程的串联把编码器和解码器串起来一个完整的交互式分割流程大概是读图 → 预处理 → 跑编码器得到image_embed→ 用户点击获取 prompt → 跑解码器得到掩码 → 后处理可视化。项目里的test_app应该就是干这个的你可以直接编译运行看效果。编译命令大概是mkdir build cd build cmake .. make -j$(nproc) ./cppsam ../test_image.jpg如果编译时报头文件找不到检查OpenVINO_DIR和OpenCV_DIR是否设置正确。在 VSCode 里如果 IntelliSense 报红但实际能编译通过那是c_cpp_properties.json里的includePath没配好把 OpenVINO 的include目录加进去就行这个报红不影响实际构建。5. 避坑与排查部署 SAM 时最容易翻车的五个地方5.1 现象推理输出全黑或全白掩码没有任何有效区域原因预处理阶段的归一化参数和模型导出时不一致。SAM 原始实现用的是mean[123.675, 116.28, 103.53]、scale[58.395, 57.12, 57.375]如果你在 Python 导出时已经把归一化做进了模型C 侧又做了一遍输入分布就完全错了。解决确认归一化只做一次要么全在 Python 侧做要么全在 C 侧做不要两边都做。5.2 现象OpenVINO 转换时报Unsupported operation: aten::xxx原因ONNX opset 版本和 OpenVINO 版本不匹配。opset 17 里的某些算子需要 OpenVINO 2023.1 以上才能支持。解决先查 OpenVINO 版本如果低于 2023.1要么升级要么在导出 ONNX 时把opset_version降到 15。降 opset 之后重新导出再跑一遍 Python 推理确认精度没掉。5.3 现象C 编译通过但运行时报Cannot find model file原因core_.read_model的路径不对或者.bin文件和.xml文件不在同一个目录。OpenVINO 读 IR 模型时要求.xml和.bin同名且同目录。解决用绝对路径或者确认工作目录正确。如果是从 build 目录运行../ir_model/sam_image_encoder.xml这种相对路径要写对。5.4 现象INT8 量化后掩码边缘变得很粗糙IoU 明显下降原因校准集和实际推理数据的分布差异太大或者stat_subset_size太小导致量化参数估计不准。解决校准集尽量用和实际场景接近的图像数量不少于 200 张。如果精度还是不行只对 Image Encoder 做 INT8Prompt Decoder 保持 FP16。另外preset从performance改成accuracy也能缓解但速度会慢一些。5.5 现象多线程调用时推理结果错乱或程序崩溃原因ov::InferRequest不是线程安全的多个线程共用一个 request 会导致数据竞争。解决每个线程创建独立的InferRequest或者用ov::CompiledModel::create_infer_request()为每个线程分配一个。如果并发量不大加一把互斥锁也能凑合但会牺牲吞吐。6. 进阶技巧用异步推理和动态 batch 把吞吐再拉一档前面讲的都是同步推理一张图跑完再跑下一张。实际服务里如果 QPS 稍微高一点同步模式会把 CPU 利用率压得很低。OpenVINO 的异步 API 可以让多个推理请求并行执行把 CPU 的多个核心吃满。下面是一个异步推理的典型写法// 创建多个推理请求数量一般设为 CPU 物理核心数 std::vectorov::InferRequest requests; for (int i 0; i 4; i) { requests.push_back(compiled_model_.create_infer_request()); } // 为每个请求设置回调推理完成后自动触发 for (auto req : requests) { req.set_callback([](ov::InferRequest request, std::exception_ptr ex) { if (ex) { // 处理异常 return; } // 处理推理结果 auto output request.get_output_tensor(); // ... }); } // 提交任务 for (size_t i 0; i images.size(); i) { auto req requests[i % requests.size()]; req.set_input_tensor(preprocess(images[i])); req.start_async(); } // 等待所有请求完成 for (auto req : requests) { req.wait(); }异步推理的关键是请求数量要匹配 CPU 核心数。设太多会导致上下文切换开销设太少则吃不满 CPU。一般从物理核心数开始试比如 8 核 CPU 设 8 个请求然后根据benchmark_app的吞吐数据微调。另一个技巧是动态 batch如果你要处理一批图可以把 batch size 设成 4 或 8一次推理多张图。但 SAM 的 Image Encoder 输入是固定的 1024×1024动态 batch 需要在导出 ONNX 时设置dynamic_axes{input_image: {0: batch}}然后 OpenVINO 转换时也要对应设置。动态 batch 的代价是首次推理会有 shape 编译开销后续同 shape 的请求会复用编译结果。验证异步推理效果最直接的方法是用benchmark_app跑一下benchmark_app -m ./ir_model_int8/sam_image_encoder.xml \ -d CPU \ -api async \ -niter 100 \ -nstreams 4-nstreams控制并行流数量一般设成 CPU 物理核心数。跑完之后看Throughput那一项对比同步模式下的Latency如果吞吐提升明显但延迟没有恶化太多说明异步配置是有效的。我自己的习惯是每次换硬件平台或 OpenVINO 版本都先用benchmark_app跑一轮基线再跑自己的 C 程序两边数据对不上就说明代码里有额外的拷贝或同步开销。从那以后我每次部署新模型都强制走一遍「Python 对齐 → benchmark_app 基线 → C 实测」这个流程能省掉很多来回猜的时间。希望帮到你。本文还有配套的精品资源点击获取