ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SAM模型C++本地部署:基于ONNX Runtime的完整推理链路

SAM模型C++本地部署:基于ONNX Runtime的完整推理链路 简介针对在无 Python 环境的 Windows 本地完成 Segment Anything 模型部署这一典型需求这套资料面向算法研发工程师、图像处理研究人员以及 C 部署开发者尤其适合已有 C 基础、希望摆脱 Python 依赖并快速获得可运行程序的工程师提供从零开始的工程化实现与集成思路。压缩包内共收录 2001 个文件整体约 676.44MB其中 733 个 cpp 源码与 307 个 hpp 头文件构成核心推理与图像处理逻辑py、java、c、m 等辅助代码覆盖模型转换、接口调用与工程测试html、xml、json、md、txt 等配置与说明文档则便于环境参数调整、目录检索和排错定位。已有 357 人学习下载关注度稳定适合需要在本地快速验证模型、生成图像掩膜或将其嵌入现有 C 项目的使用者。配套说明覆盖模型部署、release 程序生成、掩膜输出与常见排错要点本地离线部署和算法移植时可作完整示例直接参考。1. 把 Segment Anything 搬进 C 本地进程从 Python Demo 到可复现的推理链路做工业质检的朋友问我SAM 这玩意到底能不能进我们 C 的本地进程他们在 Python 里已经把 Segment Anything 调通了遮罩一个接一个地出可到了产线环境摄像头回传的画面要在 C 服务里直接处理不可能让它先起一个 Python 服务。这篇笔记就是干这个的把导出的 SAM ONNX 模型用 ONNX Runtime C API 在本地 CPU/GPU 上跑起来覆盖从预处理、图像编码到掩码解码的完整链路。适合两类人一类是拿 SAM 当新图像分割工具、想移植进老 C 工程的工程师另一类是刚接触本地部署、被 CMake 和 Ort 报错卡住的新手。下面不绕原理只讲能落地的选型、参数和踩坑记录。2. 先搞懂再动手SAM 的两段式架构与 ONNX Runtime 选型2.1 两段式架构图像编码器与掩码解码器为什么要拆开算SAM 的推理不是一次前向就完事。它由三块组成提示编码器Prompt Encoder、图像编码器Image Encoder和掩码解码器Mask Decoder。提示编码器处理点、框、掩码三类提示图像编码器用 ViT 把整张图编码成 256×64×64 的 embedding掩码解码器再把 embedding 和提示一起解出 256×256 的低分辨率掩码最后做 2 倍上采样回原尺寸。C 本地部署的关键就在这里图像编码器和掩码解码器是两套网络可以分开部署、分开推理。这意味着可以把高耗时的图像编码阶段做成预计算——同一张图上无论用户给几个点编码只跑一次提示变了只重跑解码器。这种「编码一次、多次提示」的特性很适合本地服务摄像机画面先编码好用户的点击提示只过一遍轻量解码器整条链路的延迟在几十毫秒级而不是几百毫秒。这个理解会直接影响后文的会话管理做常驻服务时应该把 encoder 的 session 缓存住decoder 的 session 每次复用。不要一个请求就重新加载一次模型否则就白白把 SAM 变成了「图像分割界的大炮打蚊子」性能全耗在模型加载上。2.2 为什么选 ONNX Runtime 而不是 TensorRT 或 LibTorch接触 C 本地部署绕不开三个候选ONNX Runtime、TensorRT、LibTorch。我按「本地部署」这个前提做了一轮对比结论如下。维度ONNX RuntimeTensorRTLibTorch跨平台Windows/Linux/macOS/ARM 全覆盖CPU/GPU 都能跑绑 NVIDIA GPU且依赖 CUDA 版本跨平台但体积大CPU 版本仍有几百 MB模型体积同一份 ONNX 直接吃做层融合后体积优势明显需要转 TorchScript绕一圈部署难度CMake头文件动态库C API 封装干净要学 Tricore 和 builderC API 复杂与 C 的 ABI 兼容是历史难题动态输入多数算子支持动态 shape动态 shape 要额外优化需要 TorchScript 固定这个资源包走的就是 ONNX Runtime 这条路径推荐理由有三。一是 Meta 官方导出脚本就是往 ONNX 导的有官方支持不玄学二是 ONNX Runtime 的 C API 是 C 接口封装头文件干净不像 LibTorch 动不动和编译器版本较劲三是它同时覆盖 CPU 和 GPU你的进程里可以只加载 CPU 的 dll也能在 Jetson 上跑正好是「本地部署」最常见的两种载体。我一般会建议如果推理卡到要上 TensorRT先别急着换后端。ONNX Runtime 的 CUDA 版本在多数生产卡上已经能跑到比较理想的速度而 TensorRT 做层融合虽快但换一次模型可能就得重新 build 一次 engine对工程迭代是隐藏成本。第 4 章的代码全部基于 ONNX Runtime C API照着跑就行。3. 拿到模型之后的第一件事导出、验证与 C 工程骨架3.1 依赖清单与 CMake 骨架版本差异不是玄学拿到一份 C 部署资源包先别急着 build检查三样东西onnxruntime 的 include 和 lib 目录、CMakeLists、示例 cpp。三样里最容易翻车的是版本。ONNX Runtime 官方导出脚本可选--opset 12到--opset 17C 运行时版本不能低于导出时的 opset否则加载模型时会报 unsupported operator这个报错看起来像模型损坏其实纯粹是版本不匹配。第二个依赖是 OpenCV。图像读取、resize、颜色转换都用它。注意如果你机器上之前装过 Python 的 cv2C 这边要单独链接 OpenCV 的 C 库和 Python 无关别改了一个路径就以为两个能共用。CMake 至少 3.16否则 ONNX Runtime 的 cmake config 文件会找不到。一个能编译通过的骨架长这样cmake_minimum_required(VERSION 3.16) project(sam_cpp_demo) set(CMAKE_CXX_STANDARD 17) find_package(ONNXRuntime REQUIRED) find_package(OpenCV REQUIRED) add_executable(sam_demo src/main.cpp src/image_preprocess.cpp src/mask_decoder.cpp ) target_link_libraries(sam_demo PRIVATE onnxruntime::onnxruntime ${OpenCV_LIBS} )这个 CMakeLists 是简化版。如果你拿到的资源包里比我这个还简化比如直接 link 了绝对路径的 .lib那大概率只能在作者自己机器上编译过换一台机器就要改路径属于典型的「本地能跑、生产翻车」写法。我拿到包的第一件事就是把这种硬编码路径改成 find_package 形式值得花十分钟。3.2 导出 ONNXencoder 和 decoder 分开导出官方仓库的脚本是export_onnx_model.py导出时会用 PyTorch 把模型转成 ONNX输出 encoder 和 decoder 两个文件。命令一般长这样python export_onnx_model.py \ --checkpoint ./sam_vit_b_01ec64.pth \ --model-type vit_b \ --opset 17 \ --output ./onnx/这里几个参数值得细看。--model-type vit_b对应 basevit_l 对应 largevit_h 对应 huge。vit_b 精度够用、速度最快适合 CPU 本地部署vit_h 效果好但在 CPU 上慢到没法用如果你没有 GPU别选。--opset 17对应算子集版本C 运行时版本不能低于 17建议先确认手头的 onnxruntime 版本再定 opset。导出完拿到两个文件image_encoder.onnx 和 mask_decoder.onnx。如果你拿到的是一整个合并的模型那就不是这种拆开的优化版本推理时灵活性会差很多。我推荐用拆开的编码器跑一次保留中间结果提示次数再多也无所谓。3.3 用 Python 先验导出的模型别急着碰 C无论如何在 C 组装输入输出之前先用 Python 的 onnxruntime 把导出的模型完整跑一遍给后文 C 实现一个对照标准而不是「感觉对了」就往下走。这个脚本同时是后续所有 debug 的基线。import onnxruntime as ort import numpy as np sess ort.InferenceSession(image_encoder.onnx) img np.random.rand(1, 3, 1024, 1024).astype(np.float32) out sess.run([image_embeddings], {images: img})[0] print(out.shape) # 期望 (1, 256, 64, 64)如果这里的 shape 不是 (1, 256, 64, 64)先别往下走你的导出版本和预期不一致C 照抄也会错。同理decoder 也要验一遍构造 point_coords、point_labels、mask_input、has_mask_input 四个输入确认 masks 输出是 [1, 1, 256, 256]。这个脚本跑通之后C 侧的一切问题都可以对照 Python 来定位它既是验证脚本也是调试黑匣子的钥匙。4. 推理链路逐段拆解预处理、编码器、解码器与后处理4.1 工程结构与资源管理一个本地部署资源包的 C 部分通常给到这几个文件include/sam_model.h 封装 session 创建和推理入口src/image_preprocess.cpp 处理图像读取、resize、转 float、归一化src/mask_decoder.cpp 拼提示张量、调 decoder、后处理main.cpp 演示加载模型、读取图片、输入点提示、输出掩码图。class SamModel { public: SamModel(const std::string encoder_path, const std::string decoder_path); cv::Mat predict(cv::Mat image, std::vectorcv::Point pts, bool is_positive); private: std::unique_ptrOrt::Session encoder_; std::unique_ptrOrt::Session decoder_; };两个 session 分别持有。注意Ort::Session是 move-only 类型必须用unique_ptr放成员位置否则编译会报 C2280 之类的错说拷贝构造函数被删了。很多人第一步就卡在这资源包编译不过原因就是成员变量直接写了Ort::Session。这个错很典型本意是「不要拷贝 session」编译器把话说得很清楚就是头一回见的容易懵。4.2 图像预处理resize 到 1024 与归一化SAM 的官方预处理没有保持纵横比直接缩放到 1024×1024这一点和很多检测模型不同。你习惯性地做 letterbox反而会把后续掩码坐标搞乱。我们这里直接 resize然后除以 255 归一化再做 ImageNet 的 mean/std。std::vectorfloat preprocess(const cv::Mat image) { cv::Mat rgb; cv::cvtColor(image, rgb, cv::COLOR_BGR2RGB); cv::Mat resized; cv::resize(rgb, resized, cv::Size(1024, 1024), 0, 0, cv::INTER_LINEAR); const float mean[3] {0.485f, 0.456f, 0.406f}; const float std[3] {0.229f, 0.224f, 0.225f}; std::vectorfloat data(1 * 3 * 1024 * 1024); for (int c 0; c 3; c) { for (int h 0; h 1024; h) { for (int w 0; w 1024; w) { float p resized.atcv::Vec3b(h, w)[c] / 255.0f; data[c * 1024 * 1024 h * 1024 w] (p - mean[c]) / std[c]; } } } return data; }代码里的c * 1024 * 1024 h * 1024 w把通道放在最外层得到的是 NCHW 布局正好是 ONNX 里 [1,3,1024,1024] 期望的排列。如果你写成(h * 1024 w) * 3 c那就是 NHWC模型输出会完全乱掉。这一点在 C 里没有 PyTorch 自动帮你搞必须自己盯。这里没有 padding 逻辑因为没做 letterbox。如果你后续为了项目里坐标对齐而改这部分记得所有提示坐标也要同步修正到 1024 空间这个坑在第五章还会再提。4.3 图像编码器推理把图片张量喂给 encoder预处理完拿到std::vectorfloat接下来把它包装成Ort::Value跑一次 encoder 前向取 image_embeddings。Ort::MemoryInfo mem_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); std::vectorint64_t input_shape {1, 3, 1024, 1024}; Ort::Value input_tensor Ort::Value::CreateTensorfloat( mem_info, data.data(), // float 指针 data.size(), // float 元素个数 input_shape.data(), input_shape.size() ); std::vectorconst char* input_names {images}; std::vectorconst char* output_names {image_embeddings}; auto outputs encoder_session.Run( Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), output_names.size() ); float* embedding outputs[0].GetTensorMutableDatafloat(); // 形状 [1, 256, 64, 64]共 1048576 个 float这里最常见的错误是data.size()传成了字节数而不是元素个数。CreateTensorfloat模板的参数要的是 float 的个数1024×1024×3 3145728如果误传了字节数等于把 int 型大小 pointer 交给运行时内存直接越界程序段错误半天找不到原因。我头一次跑也踩了这个gdb 里栈早被冲掉了最后是逐行打印 shape 才定位到。4.4 掩码解码器把点提示拼接成张量decoder 的输入比 encoder 多好几个image_embeddings、point_coords、point_labels、mask_input、has_mask_input、orig_im_size。其中 point_coords 是 1×N×2 的 float 坐标point_labels 是 1×N 的 0/1 标签mask_input 初始给 1×1×256×256 全 0has_mask_input 给 0orig_im_size 存原图高宽。一个常犯的错误是点坐标没有换算到 1024 空间。我们的预处理直接把图缩放到 1024×1024那提示坐标也必须同步缩放float scale_x 1024.0f / original_width; float scale_y 1024.0f / original_height; std::vectorfloat coords { pt.x * scale_x, pt.y * scale_y }; std::vectorfloat labels { is_positive ? 1.0f : 0.0f };可能你会想为什么不像检测模型那样直接传原图坐标让模型内部处理SAM 导出成 ONNX 后坐标是不做任何内部缩放的输入什么就拿什么算所以这个缩放系数必须在 C 侧完成。多点提示时要逐个点换算不要用 cv::Mat 的 scale 方法批量做那会把坐标连同图像一起变。4.5 后处理sigmoid、阈值与上采样回原图decoder 输出三个量masks、iou_predictions、low_res_masks。我们要用的是 masks它是 1×1×256×256 的原始预测图数值是 logits 而不是概率。直接拿来当掩码是不对的需要先做 sigmoid 压到 0 到 1 之间再做阈值分割。float* mask_logits outputs[0].GetTensorMutableDatafloat(); for (int i 0; i 256 * 256; i) { mask_logits[i] 1.0f / (1.0f std::exp(-mask_logits[i])); } cv::Mat mask_1024(256, 256, CV_32F, mask_logits); cv::Mat mask_original; cv::resize(mask_1024, mask_original, cv::Size(original_width, original_height)); cv::imwrite(mask.png, mask_original * 255);阈值我一般用 0.0因为 sigmoid(0) 0.5。大于 0.5 视为前景比较符合直觉如果你想要更严格的分割可以调高到 0.2 甚至 0.5 再试。到这里一个点提示的 SAM C 本地部署就走通了从图片输入到掩码输出整条链路闭环。5. 避坑我们在 SAM 的 C 部署里踩过的五个深坑5.1 输出掩码整片空白归一化没做全现象跑出来的 mask 全是 0阈值怎么调都是空白。原因预处理时只做了除以 255没有做 mean/std 归一化或者把 resize 写成了 letterbox。SAM 的 ViT 编码器对输入分布极其敏感缺了 normalizationembedding 直接飘掉。解决必须按(像素/255 - mean) / std处理mean 和 std 用 ImageNet 那组常数同时确认 resize 到 1024×1024 时没有保持纵横比。如果你确实要 letterbox那么后文的坐标缩放和 mask 回原图两步也要同步改这个牵连面很大建议从直接 resize 开始。预防把预处理单独抽成函数用同一份单元测试覆盖三种情况——正方形图、横向长图、竖向长图三种图的输出 shape 都必须一致。5.2 提示点坐标偏到左上角坐标没乘缩放系数现象提示点明明点中了物体mask 却偏到画面左上角位置完全不对。原因提示坐标没乘缩放系数直接把原图坐标喂给了 1024 空间里的 decoder。SAM 的输入空间是 1024×1024你传 1920 宽的原图坐标进去模型在 1024 空间里根本找不到那个位置。解决严格按x_new x * 1024 / original_width、y_new y * 1024 / original_height换算。多点提示时每个点单独算不要整体缩放。这个换算要放在 encoder 推理之后、decoder 推理之前因为 embedding 不需要坐标坐标只属于 decoder 输入。预防在 SamModel 里保存原图宽高predict 接口里统一完成坐标换算这样调用方永远传原图坐标换算逻辑集中在内部不容易漏。5.3 C 程序崩溃Ort::Value 的数据源被析构现象程序运行时崩溃错误指向 Ort::Value有时候伴随 access violation c0000005多见于 Windows 下。原因把局部std::vector传给CreateTensor函数结束 vector 被析构但 session 还持有这个内存指针后续推理读取的就是悬空指针。另一种常见是把字节数当成 float 元素个数比如传了data.size() * sizeof(float)内存越界后同样段错误。解决输入数据的 vector 生命周期必须覆盖整次推理用成员变量或智能指针持有确认元素个数参数传的是 float 个数不是字节数。如果你要在循环里反复推理可以在外面创建一个大 buffer重复使用避免每次 new 和 delete 的抖动。预防gdb bt 看调用栈十次有九次是 CreateTensor 时 data 源失效。养成先打印 shape 再跑 run 的习惯花两秒验证数据长度比在崩溃后猜快得多。5.4 第一次推理奇慢动态 shape 触发重编译现象同一张图第一次推理要十几秒第二次才开始正常换个尺寸的图又卡一次。原因session 没有开图形优化或者输入 shape 是动态的导致每次前向都要重新做 shape 推断和内存计划。ONNX Runtime 遇到动态维度时会走 fallback 路径CPU 上代价很高。解决SessionOptions 里开ORT_ENABLE_ALL优化等级同时把 encoder 输入 shape 固定为 1,3,1024,1024。固定 shape 后模型内部可以预计算 buffer显存占用也更可控这在第 6 章会再展开。预防如果你的应用只用单点提示把 decoder 的 point_coords 也固定成 1×1×2point_labels 固定成 1×1动态维度又少两个推理路径更稳。5.5 mask 和 Python 对比 IoU 只有 0.85NCHW/NHWC 混用现象肉眼看起来边界差不多但用 Dice 或 IoU 量化对比 Python 侧结果只有 0.85边界还歪歪扭扭。原因NCHW 和 NHWC 布局搞混或者 mask_input 没给全 0又或者解码后直接用了原始 logits 没有做 sigmoid。三点里任何一点都会导致像素位置错位或数值偏移量化指标自然上不去。解决确认输入张量布局是 NCHWmemset 清零 mask_input 再传给 decoder最后对比时一定是对 sigmoid 后的 mask 做 IoU不能拿 logits 直接比。先用 Python 侧同图同提示做基线输出把两边的输出保存成 npy用脚本算逐像素差异能快速定位是布局问题还是数值问题。预防写一个对比工具输入同一张图和同一组提示点输出 C 侧和 Python 侧的 IoU。每次改代码都跑一遍低于 0.95 就不算过。这个工具比任何「调一下就好」都可靠。6. 进阶固定 shape、线程数与精度验证三板斧先说固定 shape。ONNX Runtime 对静态 shape 有专门的优化路径可以提前计算每层 buffer 大小、复用内存不用每次前向做 shape 推断。对 SAM 来说encoder 输入天然是 1×3×1024×1024 固定decoder 的 point_coords 如果应用里只支持单点提示可以固定成 1×1×2这样 decoder 的动态维度从 2 个降到 1 个。实测单次解码延迟能再降 5% 到 10%而且输出 buffer 可以预分配彻底避免推理过程中的内存碎片。再说线程数。ONNX Runtime 的 intra_op 线程默认等于 CPU 核数。在 8 核机器上跑 4 个并发请求线程抖动会吃掉不少性能。我一般把 intra_op 设成 4inter_op 设成 2然后在请求级别做互斥保证 encoder 和 decoder 的 session 不并发写。这里的经验是不要盲目给满线程并发的收益在 SAM 这种大模型上远不如单请求延迟优化来得直接。最后是精度验证三板斧。第一固定随机种子和固定一张测试图保证每次实验的输入完全一致。第二C 与 Python 跑同一张图、同一个提示点分别输出 mask存成 png再计算 Dice 系数。第三把验证做成命令行工具每次发版强制跑一遍而不是肉眼看一两张图就觉得没问题。具体做法是在 main.cpp 里加一个--verify参数读入测试图、提示点、Python 侧 golden mask计算 Dice 后直接打印结果。Dice 大于 0.95 视为通过否则直接返回非零退出码CI 直接失败。这样每一次改动都有据可查不再靠「上一次跑的时候还是好的」这种玄学来撑着。从那以后我每接一个新的本地部署资源都会强制走一遍「Python 验导出、C 复现、固定 shape 压性能」这个顺序顺序不乱排查效率高得多。尤其是 SAM 这类模型导出脚本、onnxruntime 版本、输入布局、坐标缩放每一步都可能埋雷只有把所有环节都固定成可复现的流程才能在换机器、换模型版本时不吃后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表