首页 / 资讯中心 / 文章详情

SAM边缘部署:基于ONNX与OpenVINO的C++推理实战

SAM边缘部署:基于ONNX与OpenVINO的C++推理实战 ★ FEATURED ARTICLE
简介面向需要将SAM分割模型部署到实际业务的计算机视觉开发者这份基于ONNX与OpenVINO工具链的C实现教程提供了从模型导出、格式转换到推理优化的完整落地路径。压缩包共32个文件涵盖C源文件.h/.cpp、Python转换脚本.py、CMake构建配置、说明文档与许可协议并附带测试图片和示例输出整体大小约2.23MB目录按cpp、src、docs、pyth等模块划分便于按需查阅。已有170人学习该资源。内容覆盖SAM模型ONNX导出、OpenVINO执行器封装、分割示例程序及依赖环境说明同时保留备份文件与.gitignore等工程管理细节。对于希望在智能监控、自动驾驶或医学影像中快速集成分割能力的开发者可直接参考vino_executor与cppsam模块的封装思路结合test_app示例进行二次开发。1. 边缘设备跑SAM为什么ONNX与OpenVINO是可行路径“SAM”在本文里特指Meta的Segment Anything Model跟语音合成里的那个SAM不是一回事。拿一张工业零件图用户点一下就要抠出缺陷轮廓PyTorch里效果好但部署到工控机的CPU上就暴露问题了——ViT-H这种量级的模型直接推理一秒出图都是奢望。换一条技术链路能把这个现状彻底改变模型先用ONNX导出再用OpenVINO做图优化与算子融合最后用C API集成进产线程序。ONNX解决模型格式通用性的问题OpenVINO解决CPU与集显上推理速度的问题C解决工程接入的问题。这套方案适合手里已有PyTorch权重、想脱离GPU做边缘部署的开发者也适合C为主语言的产线集成团队。2. 从PyTorch到ONNX把SAM按推理链路拆成两份子模型2.1 为什么不导出完整SAM模型而是按encoder/decoder拆两份SAM的结构是典型的“大编码器小编码器”。image encoder负责把1024×1024图像编码成256×64×64的embeddingprompt encoder和mask decoder负责把点、框、掩码变成最终分割结果。如果直接把整个SAM导出成一份ONNX模型图会很大而且推理时每次都要重跑image encoder浪费严重。实际部署时图像特征只需要算一次。用户在图上点了若干个点每个点只需要重跑轻量的mask decoder。拆成image_encoder.onnx和mask_decoder.onnx两份既能让运行时灵活组合也方便后续针对重量级encoder单独做INT8量化。单独导出还有第三个好处——调试方便。哪一段输出不对直接定位是哪份模型的问题不用在一张大图里翻来翻去。2.2 用torch.onnx.export导出image encoder最小可运行命令import torch from segment_anything import sam_model_registry sam sam_model_registry[vit_h](checkpointsam_vit_h_4b8939.pth) sam.eval() torch.onnx.export( sam.image_encoder, torch.randn(1, 3, 1024, 1024), image_encoder.onnx, input_names[images], output_names[embeddings], opset_version17, dynamic_axes{images: {0: batch}, embeddings: {0: batch}}, )这段代码把ViT-H的图像编码器单独拿出来导出。输入形状固定为[1, 3, 1024, 1024]这是SAM官方要求的输入分辨率resize和padding都在外部做模型内部不处理。opset_version选17兼顾了ONNX Runtime和OpenVINO的兼容性。dynamic_axes只放开batch维度意味着你可以一次塞多张图提特征但图像尺寸仍然锁定1024×1024。需要特别说明的是导出用CPU就可以速度稍慢但结果正确。如果你的PyTorch版本比较旧opset 17可能不被支持那就往下调到13代价是某些新算子会被拆成更细粒度的组合文件稍大一点。检查导出是否成功用onnx.checker.check_model(image_encoder.onnx)跑一遍不报错再进入下一步。2.3 导出mask decoderprompt输入张量与前处理开关from segment_anything.utils.onnx import SamOnnxModel onnx_model SamOnnxModel( modelsam, return_single_maskTrue, return_extra_metricsFalse, use_stability_scoreFalse, ) torch.onnx.export( onnx_model, ( torch.randn(1, 256, 64, 64), torch.randn(1, 1, 2), torch.randint(0, 4, (1, 1)), torch.randn(1, 1, 256, 256), torch.randn(1, 1, 1), ), mask_decoder.onnx, input_names[ image_embeddings, point_coords, point_labels, mask_input, has_mask_input, ], output_names[masks, iou_predictions], opset_version17, )这里的输入形状和官方onnx.py保持一致。image_embeddings是encoder的输出形状[1, 256, 64, 64]point_coords是[1, 1, 2]一个点两个坐标point_labels是[1, 1]而不是[1, 1, 1]标签值为0表示背景、1表示前景导出时随便填一个占位mask_input是上一次推理得到的低分辨率mask首轮用全零has_mask_input置0表示本轮不带mask提示。这三个开关参数值得展开说。return_single_maskTrue让模型只输出一张mask后处理简单如果设成False会输出三个候选mask你还得自己写筛选逻辑。use_stability_scoreFalse是为了减少导出算子的种类稳定性评分计算会引入额外的数学运算在ONNX里容易踩算子兼容问题。return_extra_metrics直接关掉部署场景用不到。导出需要的文件就三样sam_vit_h_4b8939.pth权重、sam_model_registry、segment_anything.utils.onnx里的SamOnnxModel封装。版本对应关系要检查清楚权重和代码库版本不匹配会出现导出成功但推理结果全是噪声的情况。3. OpenVINO转换把ONNX变成IR顺便确认前端兼容性3.1 ov.convert_model与mo命令行选哪条路ONNX文件拿到手下一步就是交给OpenVINO。老一代工程师习惯用mo命令行OpenVINO 2023之后官方更推荐Python API的ov.convert_model它不需要单独装openvino-dev的tool链转换结果能直接拿到内存里做检查。import openvino as ov ov_model ov.convert_model( image_encoder.onnx, input[[1, 3, 1024, 1024]], ) ov.save_model(ov_model, image_encoder.xml)convert_model会返回一个ov.Model对象再通过save_model落盘为XML和BIN两个文件这就是OpenVINO的IR格式。input参数在这里显式指定静态shape避免ONNX里dynamic_axes带来的不确定性。如果你的ONNX是固定shape这个参数可以省略。mo命令行方式是等价的适合脚本化批处理场景但参数写起来更繁琐而且新版OpenVINO对mo的依赖在减弱。我的建议是统一用Python API把转换脚本和后续的精度验证脚本放在同一个工程目录里出问题好排查。3.2 静态shape与动态shape的取舍以及FP16压缩的影响部署时最常犯的错误是把dynamic_axes全放开导出ONNX然后直接丢给OpenVINO。动态shape在OpenVINO里不是不能用但CPU端推理时每次都要做shape推导性能明显下降。ViT-H这种超大模型shape推导开销会被放大。所以生产环境一律固定shapebatch固定成1。精度模式文件大小CPU推理相对耗时典型场景FP32基准1.0精度优先内存充足FP16约一半0.7-0.8内存受限容忍微小精度波动INT8约四分之一0.4-0.5追求高吞吐配合校准集sam_model_registry这个表是OpenVINO部署里最重要的三选。FP16压缩在OpenVINO里是save_model时的一个参数compress_to_fp16True文件体积直接砍半。但image encoder的输出是embedding它的数值精度直接影响分割边缘质量。ViT-H模型对embedding的精度损失比较敏感我一般建议image encoder保留FP32mask decoder这种轻量模型用FP16就够了。INT8量化不是OpenVINO自动完成的需要额外用NNCF做后训练量化校准集的质量决定量化效果。这个在后面的章节展开写这里只需要记住INT8不是免费的校准集选不好分割结果会明显劣化。3.3 转换后立即做的输出对齐验证转换完成别急着写C先用Python做一次数值对齐验证。这一步能过滤掉绝大多数前端兼容性问题。import numpy as np import onnxruntime as ort import openvino as ov np.random.seed(0) x np.random.randn(1, 3, 1024, 1024).astype(np.float32) ort_sess ort.InferenceSession(image_encoder.onnx, providers[CPUExecutionProvider]) ort_out ort_sess.run(None, {images: x})[0] model ov.convert_model(image_encoder.onnx, input[[1, 3, 1024, 1024]]) compiled ov.Core().compile_model(model, CPU) ov_out compiled(x)[0] print(max diff:, np.abs(ort_out - ov_out).max())两者都是CPU浮点推理理论上最大误差应该在1e-3量级。OpenVINO做算子融合后会引入微小数值差异这个正常。但如果diff到了1e-1说明某个算子被错误转换直接拿这个diff值去OpenVINO的issue里搜通常能找到答案。输入数据用随机数就好不需要真实图片这个步骤考核的是算子兼容性不是语义正确性。mask_decoder的验证方式一样用固定输入跑两个运行时对比输出。我习惯把两份ONNX的数值验证写进一个Python脚本每次改模型后先跑一遍再继续往下走。4. 用C实现完整推理链路OpenVINO API 2.0写一遍4.1 在C里加载IR并完成SAM要求的预处理C部署用OpenVINO推荐的是API 2.0头文件是openvino/openvino.hpp命名空间是ov。旧版的InferenceEngine::CNNNetwork已经不建议在新项目里用了。下面代码把image encoder的推理和预处理一起实现。#include openvino/openvino.hpp #include opencv2/opencv.hpp #include vector #include cmath int main() { ov::Core core; auto enc_model core.read_model(image_encoder.xml); auto compiled_enc core.compile_model(enc_model, CPU); auto enc_req compiled_enc.create_infer_request(); cv::Mat img cv::imread(input.jpg, cv::IMREAD_COLOR); cv::Mat rgb; cv::cvtColor(img, rgb, cv::COLOR_BGR2RGB); float scale 1024.0f / std::max(rgb.cols, rgb.rows); int new_w static_castint(std::round(rgb.cols * scale)); int new_h static_castint(std::round(rgb.rows * scale)); cv::Mat resized; cv::resize(rgb, resized, cv::Size(new_w, new_h)); cv::Mat canvas cv::Mat::zeros(1024, 1024, CV_32FC3); cv::Mat resized_f; resized.convertTo(resized_f, CV_32FC3); resized_f.copyTo(canvas(cv::Rect(0, 0, new_w, new_h))); ov::Tensor input_tensor(ov::element::f32, {1, 3, 1024, 1024}); float* data input_tensor.datafloat(); const float mean[3] {123.675f, 116.28f, 103.53f}; const float std[3] {58.395f, 57.12f, 57.375f}; for (int c 0; c 3; c) { for (int h 0; h 1024; h) { for (int w 0; w 1024; w) { float val canvas.atcv::Vec3f(h, w)[c]; data[c * 1024 * 1024 h * 1024 w] (val - mean[c]) / std[c]; } } } enc_req.set_input_tensor(input_tensor); enc_req.infer(); ov::Tensor embedding enc_req.get_output_tensor(); // embedding 形状为 [1, 256, 64, 64] }预处理这部分是SAM部署翻车的高发区。SAM官方的图像transform是ResizeLongestSide也就是把最长边缩放到1024短边按比例缩放剩余区域用0填充成正方形。直接cv::resize到1024×1024的拉伸做法是错误的会导致mask输出和原图对不上位置。上面代码用canvas垫底实现padding只有左上角的new_w×new_h区域有图像内容。mean和std用的是SAM训练时的ImageNet统计值数值范围是0-255。注意这里虽然做了BGR转RGB但OpenCV的Mat是HWC布局手动循环转成CHW时按通道顺序读取RGB顺序和训练时保持一致。4.2 用prompt坐标驱动mask decoder输出分割结果image encoder推理一次拿到embedding之后mask decoder可以反复被调用。用户每点一个点只需要重跑decoder延迟很低。auto dec_model core.read_model(mask_decoder.xml); auto compiled_dec core.compile_model(dec_model, CPU); auto dec_req compiled_dec.create_infer_request(); float click_x 100.0f, click_y 80.0f; ov::Tensor coords(ov::element::f32, {1, 1, 2}); float* cd coords.datafloat(); cd[0] click_x * scale; cd[1] click_y * scale; ov::Tensor labels(ov::element::i64, {1, 1}); int64_t* ld labels.dataint64_t(); ld[0] 1; ov::Tensor mask_input(ov::element::f32, {1, 1, 256, 256}); std::fill(mask_input.datafloat(), mask_input.datafloat() 256 * 256, 0.0f); ov::Tensor has_mask(ov::element::f32, {1, 1, 1}); has_mask.datafloat()[0] 0.0f; dec_req.set_input_tensor(image_embeddings, embedding); dec_req.set_input_tensor(point_coords, coords); dec_req.set_input_tensor(point_labels, labels); dec_req.set_input_tensor(mask_input, mask_input); dec_req.set_input_tensor(has_mask_input, has_mask); dec_req.infer(); ov::Tensor masks dec_req.get_output_tensor(masks); // masks 输出形状为 [1, 1, 256, 256]float 类型的 logits坐标换算是个易错点。click_x和click_y是原图坐标必须乘上前面算出的scale才能映射到1024×1024的输入空间。因为padding只发生在右侧和下侧坐标映射不需要额外偏移。mask_input第一次推理用全零。如果做多轮交互上一轮输出的256×256 mask可以传回来当作提示配合has_mask_input置1使用。注意labels用了int64这是为了和PyTorch导出时point_labels的dtype对齐用错类型会导致OpenVINO报shape或type mismatch。推理结束后masks里的值是logits不是概率。用sigmoid做激活然后以0为阈值转成二值mask再resize回原图尺寸显示。很多教程漏了sigmoid这步直接拿原始logits做阈值分割结果会多出一圈低置信度的假阳性区域。4.3 备用路径ONNX Runtime C API的mask decoder写法有些项目里已经引入了ONNX Runtime做YOLO等模型的推理为了统一runtime栈decoder部分可以走ORT而不是OpenVINO。image encoder因为追求性能和后续的INT8量化仍然推荐用OpenVINO。#include onnxruntime_cxx_api.h Ort::Env env(ORT_LOGGING_LEVEL_WARNING); Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); Ort::Session sess(env, mask_decoder.onnx, opts); std::vectorconst char* input_names { image_embeddings, point_coords, point_labels, mask_input, has_mask_input }; std::vectorconst char* output_names {masks, iou_predictions}; std::vectorOrt::Value input_tensors; input_tensors.push_back(Ort::Value::CreateTensorfloat( mem_info, embedding_data, embedding_size, embedding_shape, embedding_shape_len)); auto outputs sess.Run(Ort::RunOptions{nullptr}, input_names.data(), input_tensors.data(), input_tensors.size(), output_names.data(), output_names.size());ORT的C API比OpenVINO繁琐一些但胜在跨平台一致性。同一份ONNX跑到Windows和Linux行为完全一致。而OpenVINO不同版本之间IR格式兼容性偶尔会出幺蛾子这个是后面要说的翻车点。选择哪条路取决于团队已有依赖如果工程里已经有一堆ORT代码decoder走ORT能少引入一个推理引擎如果从零开始直接全程OpenVINO。5. 部署避坑从转换到C推理最常见的五个翻车点5.1 现象OpenVINO转换时报Unsupported operation把ONNX丢给ov.convert_model报错说某几个算子是Unsupported operation整个转换中止。原因通常是PyTorch导出的算子太新当前OpenVINO版本还不认识。解决方法是先把OpenVINO升级到最新发布版如果仍然报错把opset_version从17降到13再重新导出。降opset会引入更多细粒度算子能绕开个别不兼容的算子。另外use_stability_scoreTrue导出的模型最容易踩这个坑因为它会引入非标准的数学函数算子。5.2 现象FP16压缩后mask边缘破碎、出现颗粒感模型转换后用FP16压缩文件小了但分割出来的边缘像锯齿还频繁出现孤立像素点。查过预处理没问题。原因出在image encoder的输出embedding对数值精度敏感FP16的尾数不够用。这类模型里最常见的就是embedding层和attention层输出它们一压缩就掉点。解决方法是只对mask decoder做FP16压缩image encoder保持FP32。memory确实吃紧时可以单独给image encoder做INT8量化但校准集得好好准备。5.3 现象C推理结果和PyTorch差很多mask位置完全对不上PyTorch离线测试效果很好换成C部署后整个mask偏离目标。误以为OpenVINO转换出了问题但Python端openvino验证又是对的。最后定位到预处理直接用cv::resize把图像拉成1024×1024正方形而不是SAM标准的ResizeLongestSidepadding。SAM训练时图像是按长边等比缩放、短边补零如果你的预处理是暴力拉伸模型看到的就是变形的输入输出自然对不上。解决方式是严格按照4.1的canvas方式做padding坐标映射时记好scale只作用在x和y上不做偏移。5.4 现象prompt坐标在原图上分割区域却跑到别的位置用户在图上点击的位置明明在左上角但mask输出跑到图像中央。原因是point_coords用的是原始像素坐标而模型期望的坐标是在1024×1024输入空间里的坐标。原图800×600点一个(100, 80)不乘scale直接喂给decoder模型会以为你在(100, 80)这个缩放后的位置点击。解决方式就是代码里那句cd[0] click_x * scale。scale用float计算不要强转成int否则坐标偏移几个像素在边缘场景下非常明显。5.5 现象INT8模型第一次推理很慢之后才恢复正常量化后的模型加载后第一次infer耗时离谱有时候是FP32模型的十倍以上。这不是模型坏了是CPU端在第一次推理时做weight重排序和kernel选择。解决办法是启动阶段用全零输入跑一次warmup让OpenVINO完成所有预编译工作。另外在compile_model时可以传入配置ov::hint::performance_mode(ov::hint::PerformanceMode::LATENCY)或者THROUGHPUT具体看你的场景是单路低延迟还是多路高吞吐。warmup和性能模式配合基本能消除首包延迟的突兀感。6. 进阶验证INT8量化之后mask的IoU怎么测才靠谱INT8量化不是跑通就完事必须用数据说话。NNCF是OpenVINO生态里的压缩工具用真实场景图片做校准集然后对比量化前后模型输出的mask差异。import nncf import openvino as ov import numpy as np model ov.convert_model(image_encoder.onnx, input[[1, 3, 1024, 1024]]) def calib_loader(): for img in calibration_images: # 至少50张真实场景图 x preprocess(img) # 返回 [1,3,1024,1024] 的 float32 ndarray yield x quantized_model nncf.quantize(model, calib_loader()) ov.save_model(quantized_model, image_encoder_int8.xml)校准集要覆盖实际业务中会遇到的图像类型。做工业缺陷检测就用缺陷样本做遥感分割就用遥感影像不能用无关的自然图片凑数。量化后跑一轮mask对比计算两个mask的IoUdef mask_iou(a, b): a (a 0).astype(np.uint8) b (b 0).astype(np.uint8) inter np.logical_and(a, b).sum() union np.logical_or(a, b).sum() return inter / union同一个点promptPyTorch推理结果作为基准INT8 OpenVINO推理结果做对比。FP32部署IoU在0.95以上很正常INT8低于0.9就要回头检查是校准集问题还是某个层对量化过于敏感。另一个土办法是直接把量化前后的mask存成图片用肉眼扫一遍边缘是否有断裂。数值和视觉双重确认才能放心上产线。我早期做INT8量化时第一版量化后IoU只有0.82以为是ViT这种注意力模型天生不适合量化排查了一整天最后发现是预处理resize写歪了和量化毫无关系。这类越级归因的教训提醒我部署问题永远先怀疑预处理再怀疑模型转换最后才怀疑量化算法。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站