☰
LiteSeg语义分割C++部署:基于ONNX Runtime的完整推理链路实践
2026/10/10 20:12:01 网站建设 项目流程

简介:LiteSeg语义分割C++部署方案,面向需要在资源受限设备上完成实时图像分割的C++开发者。模型以ONNX格式发布,配合OpenCV的Dnn模块即可运行,无需依赖TensorFlow等重型框架,可显著降低部署成本与运行内存占用。其输入分辨率为512×512,适用于边缘检测、道路区域分割、工业质检等逐像素分类场景。资源包共4个文件,压缩后约25.94MB,主要包含ONNX模型文件、OpenCV 4.5.0的DLL动态链接库与LIB静态库,以及一个C++示例源码文件。源码详细展示了从加载模型、对输入图像做归一化与尺寸调整,到执行前向传播、获取分割掩码的完整流程,并包含了对预测概率图的阈值处理与可视化思路,开发者可直接复制改造或集成到自己的工程中。目前已有1400人学习该资源,适合具备C++和OpenCV基础、希望快速掌握轻量级语义分割模型部署方法的工程技术人员,也是边缘端视觉项目的良好参考。

1. LiteSeg语义分割C++模型部署:为什么值得把推理链路换成C++

做遥感图像语义分割、道路场景分割这类任务,最容易遇到的局面是:模型在PyTorch里跑得飞起,一拿到工控机或边缘盒子上就卡壳。LiteSeg语义分割C++模型部署,解决的正是这个环节——把训练好的LiteSeg权重导出成ONNX,再用C++推理后端加载,替换掉Python推理里解释器开销和环境依赖那部分拖累。LiteSeg本身是个轻量分割网络,不算重,但Python端跑一帧512×1024的输入,光是把数据从NumPy倒腾到PyTorch再倒腾回来,就浪费不少时间。换到C++管线,预处理、推理、后处理三段串在一起,普通CPU上也能把单帧压到几十毫秒。这篇文章就是写给手上已经有LiteSeg权重、正准备做C++服务或桌面程序的人,我把整条链路和踩过的坑从头讲一遍。

2. 先拆结构再定导出方案:LiteSeg上C++前的三件准备工作

2.1 LiteSeg的网络结构拆解:轻量Backbone加ASPP解码头

做语义分割算法落地的人,拿到LiteSeg权重时通常没法直接上C++,因为它活在PyTorch里。先看结构:LiteSeg走的是DeepLabV3轻量化路线,主干用MobileNet系列替代ResNet,解码端保留ASPP模块——一组不同扩张率的空洞卷积并联,再接1×1卷积压缩通道。空洞率常见的是6、12、18这一档,扩张卷积让网络在不下采样过多的情况下扩大感受野,对分割边界的细节保留很有帮助。相比FCN那种单纯跳连上采样的轻量方案,LiteSeg在边缘精度上更接近DeepLabV3,计算量又小一个量级。论文里给的LiteSeg-18、LiteSeg-34变体,主要差别在主干通道缩放倍数,部署时以自己训练的权重为准,不必纠结选哪个。

真正影响C++部署难度的,不是结构精度,而是算子类型。LiteSeg的构成基本就是卷积、BN、ReLU6、深度可分离卷积、空洞卷积、双线性上采样和加法拼接,这些在ONNX里全是标准算子,导出和推理都不需要写自定义plugin。TensorRT也好、ONNX Runtime也好,都能直接认。这一点直接决定后续部署工作量,我见过不少人卡在自研算子的C++实现上,而LiteSeg基本不存在这个问题。

模块常见配置对C++部署的影响
BackboneMobileNet系列,带深度可分离卷积标准算子,无需自定义实现
ASPP空洞率6/12/18并联+1×1卷积空洞卷积在各推理后端支持良好
上采样双线性插值注意插值属性和训练端对齐
输出头1×1卷积输出C类logits后处理就是argmax加上色

这张表不是某份官方文档,而是我对常见复现的归纳,核心结论是"全标准算子"。有了这个前提,后面的ONNX导出和C++推理选型才会顺。

2.2 导出ONNX:定输入输出名字、定动态轴、定opset

搞定结构之后,第一步是把PyTorch模型导出成ONNX。常见做法是直接用torch.onnx.export,但有几个参数必须在这时定死,否则C++那边要反复改代码。我一般会写一个固定的导出脚本:

import torch from model import LiteSeg # 你的模型定义,加载你自己的权重 model = LiteSeg(num_classes=19) model.load_state_dict(torch.load("litseg_weights.pth", map_location="cpu")) model.eval() # 切推理模式,冻结BN统计 dummy_input = torch.randn(1, 3, 512, 1024) torch.onnx.export( model, dummy_input, "litseg.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=12, do_constant_folding=True, )

这段脚本里最容易被忽略的是model.eval()。没切eval就导出,BN层会被当成训练模式,导出的图里残留batch维度的统计逻辑,推理结果会异常。input_names和output_names要记下来,C++的Session里必须用完全相同的字符串,我习惯统一叫input和logits,简单不易错。

dynamic_axes里我只把batch轴设成动态,H和W保持固定。分割模型在设备上一般不会频繁换输入分辨率,固定成1×3×512×1024,ONNX Runtime可以提前把中间张量内存规划好,跑起来更稳。如果业务确实需要多分辨率,再把height和width也加进dynamic_axes,但代价是每次换尺寸都可能重新分配内存,性能会抖动。

opset_version=12是保守选择,双线性上采样的属性在opset 11之后才比较完整,12以上基本稳定。do_constant_folding=True会把BN和卷积里的常量尽量折叠进权重,导出文件更小,推理时少几轮计算。导出完再用onnx.checker.check_model验证一次,用Netron打开看一遍输入输出名字和shape,这一步一分钟,能省后面一小时的调试。

注意:训练脚本里对输入做的预处理(resize、归一化、通道顺序)永远不会进ONNX图,导出时ONNX里只有网络本身。训练脚本transform里的每个参数,都要原样抄到C++侧。

2.3 推理后端选型:为什么ONNX Runtime是默认答案

LiteSeg的C++部署,后端选择是整个模型部署链路里最影响工作量的决策。常见候选有四个:ONNX Runtime、OpenVINO、TensorRT,以及TNN/NCNN这类移动端推理库。对LiteSeg这种标准卷积结构,我基本默认先用ONNX Runtime,理由看这张表:

后端适用硬件动态shape量化支持上手成本
ONNX RuntimeCPU、CUDA、多平台支持INT8/FP16低,CMake直接接
OpenVINOIntel CPU/核显支持但有限制INT8中,需要IR转换
TensorRTNVIDIA GPU固定shape最佳FP16/INT8强高,engine转换+校准
TNN/NCNN移动端/ARM一般有中,算子兼容要测

选ONNX Runtime的核心原因有三个:一是LiteSeg全标准算子,它直接吃官方onnx,不用做格式转换;二是它同时提供CPU和CUDA执行单元,同一个Session在SessionOptions里追加一个provider就能切GPU;三是CMake支持好,和OpenCV一起链接很省事。Intel平台用OpenVINO可能比ONNX Runtime更快,但要多一个onnx转IR的环节;N卡要跑满性能可以上TensorRT,但要维护engine转换和校准集,适合模型已经冻结、不再频繁改动的项目。

对绝大多数工控机方案——一台不带独显的Windows或Linux盒子——ONNX Runtime的CPU推理已经能把LiteSeg推到实时附近。先把整条链路打通,把精度基线数据留下,后面再和TensorRT做收益对比,这是我觉得最稳的节奏。深度学习模型部署最忌讳一上来就上最复杂的后端,后面所有问题混在一起没法定位。

库的获取方式也很关键。我一般用vcpkg安装onnxruntime,或者直接下载官网预编译zip,把include和lib放进工程目录。无论哪种,头文件路径和链接库路径要对上。onnxruntime版本间API有细微变化,比如GetInputNameAllocated在旧版本里叫GetInputName,选一个长期维护的版本写代码,不要追新。C++侧还有个常见问题:预编译包是Release版,工程如果是Debug构建,链接时会出莫名其妙的崩溃,直接切Release。

3. 用ONNX Runtime把LiteSeg跑起来:C++最小工程到完整管线

3.1 搭一个能编译的最小工程:CMakeLists与main.cpp骨架

这一节从空目录写到能输出分割图。先给CMakeLists.txt,一个完整可编译的最小工程:

cmake_minimum_required(VERSION 3.16) project(litseg_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) # onnxruntime 提供CMake config时可用 find_package(onnxruntime REQUIRED) # 如果用下载的预编译包,也可以手动指定: # set(ONNXRUNTIME_ROOT "D:/libs/onnxruntime-win-x64") # include_directories(${ONNXRUNTIME_ROOT}/include) # link_directories(${ONNXRUNTIME_ROOT}/lib) add_executable(litseg_demo main.cpp) target_link_libraries(litseg_demo PRIVATE onnxruntime ${OpenCV_LIBS} )

直接cmake -B build && cmake --build build就能构建。如果你习惯用vscode配置c/c++环境,加一个CMake插件也能可视化编译,底层命令是一样的。c/c++构建这块最容易出问题的是find_package找不到onnxruntime,不同版本里包名可能从onnxruntime变成Ort,这时就在CMake里手动指定ONNXRUNTIME_ROOT,最省事。

接下来main.cpp骨架。注意这里用的是ONNX Runtime C++ API,头文件是onnxruntime_cxx_api.h:

#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <iostream> #include <vector> int main(int argc, char** argv) { // 1. 创建session Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "litseg"); Ort::SessionOptions session_opts; session_opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, "litseg.onnx", session_opts); // 2. 打印输入输出名字,测试阶段必做 Ort::AllocatorWithDefaultOptions allocator; for (size_t i = 0; i < session.GetInputCount(); i++) { auto name = session.GetInputNameAllocated(i, allocator); std::cout << "input[" << i << "] = " << name.get() << std::endl; } // 3. 读图 cv::Mat img = cv::imread(argv[1]); if (img.empty()) { std::cerr << "cannot read image: " << argv[1] << std::endl; return 1; } // 4. 预处理、推理、后处理,下面两节展开 return 0; }

这段代码的逻辑很直白:先建Ort::Env,相当于一个全局运行环境;再建SessionOptions,这里把图优化等级开到全部;最后用模型路径创建Session。打印输入输出名字那步不是多余的,很多线上问题就是模型导出时名字和自己记的不一致,跑起来才暴露。

3.2 预处理:归一化、类型转换、通道重排一步到位

预处理是最容易和训练端对不上的环节。LiteSeg训练时用的输入,通常是0到1区间、按ImageNet的mean和std归一化,通道顺序是RGB。而OpenCV读出来是BGR、0到255,所以C++侧要一次性转换掉。我习惯写成一个独立函数:

std::vector<float> preprocess_input(const cv::Mat& bgr_img, int target_h = 512, int target_w = 1024) { cv::Mat rgb, resized, float_img; cv::cvtColor(bgr_img, rgb, cv::COLOR_BGR2RGB); cv::resize(rgb, resized, cv::Size(target_w, target_h), 0, 0, cv::INTER_LINEAR); resized.convertTo(float_img, CV_32FC3, 1.0 / 255.0); const float mean[3] = {0.485f, 0.456f, 0.406f}; const float std[3] = {0.229f, 0.224f, 0.225f}; std::vector<float> tensor(3 * target_h * target_w); const float* src = (const float*)float_img.data; // 输入是HWC连续内存,输出要CHW for (int c = 0; c < 3; c++) { for (int h = 0; h < target_h; h++) { for (int w = 0; w < target_w; w++) { const float* pixel = src + (h * target_w + w) * 3; tensor[(c * target_h + h) * target_w + w] = (pixel[c] - mean[c]) / std[c]; } } } return tensor; }

这段代码有两个细节要特别说。第一个是mean/std必须和训练时一致。LiteSeg很多公开复现里用的是ImageNet的mean/std,也就是0.485、0.456、0.406这一组;但Cityscapes上训练时也有人用0到255范围内的mean,比如(72.4, 84.9, 101.2)再除以255,效果和前者有差异。不确认就翻训练脚本找transform定义,把数值原样抄过来,不要凭感觉换。

第二个是resize的INTER_LINEAR。PyTorch训练时如果用PIL的Bilinear且align_corners=False,OpenCV的INTER_LINEAR和它存在亚像素对齐差异,一般不影响分割效果,但后面要做逐像素对比验收时,这里会变成一个固定误差源。我在项目里会用一张图专门对比预处理后的tensor,确认误差在可接受范围再继续。

另外,cv::dnn::blobFromImage可以一次完成resize和归一化,省掉手动循环:

cv::Mat blob = cv::dnn::blobFromImage(rgb, 1.0/255.0, cv::Size(1024, 512), cv::Scalar(0.485, 0.456, 0.406), false);

但注意blobFromImage里那个Scalar只是减均值,不负责除方差,得再手动除以std。它确实能省事,但也容易把预处理逻辑搞混,我倾向先用手写版本把流程跑通,性能优化时再合并。

3.3 推理与后处理:从Ort::Value到argmax和调色板

预处理拿到的是std::vector ,接下来把它变成Ort::Value,丢给session.Run:

std::array<int64_t, 4> input_shape{1, 3, 512, 1024}; auto memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, tensor.data(), tensor.size(), input_shape.data(), input_shape.size()); const char* input_names[] = {"input"}; const char* output_names[] = {"logits"}; Ort::RunOptions run_opts; auto output_tensors = session.Run(run_opts, input_names, &input_tensor, 1, output_names, 1); auto out_shape = output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); // 期望是 [1, 19, 512, 1024],还是打印确认最稳 std::cout << "out shape:"; for (auto dim : out_shape) std::cout << " " << dim; std::cout << std::endl; const float* logits = output_tensors[0].GetTensorData<float>();

CreateTensor 接收裸指针、元素个数和shape,这一步不会拷贝数据,所以tensor这个vector在session.Run之前不能被销毁。input_names和output_names必须和onnx里完全一致,否则直接报错。out_shape用GetShape打印,是为了防止动态shape下输出尺寸和预期不一致。接下来argmax,把[1, 19, 512, 1024]的logits压成单通道mask。注意内存布局,logits是连续内存,第i个像素的19个类别得分连在一起,所以可以这么扫:

int C = (int)out_shape[1], H = (int)out_shape[2], W = (int)out_shape[3]; cv::Mat mask(H, W, CV_8UC1); for (int i = 0; i < H * W; i++) { const float* cls_scores = logits + i * C; int best = 0; for (int c = 1; c < C; c++) { if (cls_scores[c] > cls_scores[best]) best = c; } mask.at<uchar>(i / W, i % W) = (uchar)best; }

argmax之后拿到的是类别编号,直接存成灰度图也能看,但分割图一般要上色。准备一个类别数对应的BGR调色板,把mask映射成彩色图。Cityscapes官方配色是现成的,直接用即可:

const std::vector<cv::Scalar> palette = { {128, 64, 128}, {232, 35, 244}, {70, 70, 70}, {156, 102, 102}, {153, 153, 190}, {153, 105, 75}, {40, 170, 230}, {180, 165, 30}, {60, 20, 220}, {0, 130, 200}, {255, 255, 0}, {70, 240, 240}, {250, 170, 30}, {32, 32, 200}, {110, 190, 160}, {170, 120, 50}, {60, 60, 0}, {200, 60, 0}, {0, 0, 142} }; cv::Mat color_mask(H, W, CV_8UC3); for (int h = 0; h < H; h++) { for (int w = 0; w < W; w++) { uchar id = mask.at<uchar>(h, w); color_mask.at<cv::Vec3b>(h, w) = cv::Vec3b( palette[id][0], palette[id][1], palette[id][2]); } }

到这一步,能读图、推理、输出彩色分割图的C++版LiteSeg就通了。我建议在main里用chrono或者OpenCV的getTickCount把预处理、session.Run、后处理三段分开计时,先看瓶颈在哪。很多项目一上来就调推理线程数,结果发现时间都花在resize和通道转换上,这个习惯要从第一天就养成。

4. LiteSeg C++部署避坑与排查:五个高频翻车点

4.1 推理不报错但输出全是一个类别:先核对tensor形状与输入名

现象:session.Run正常返回,argmax出来的mask几乎全是0,偶尔边缘有几个杂点,loss曲线看着还挺好的模型到了C++手里就废了。

原因:最常见的有两种。第一种是input tensor的shape和模型期望不一致,比如模型输入是1×3×512×1024,你创建的是1×3×1024×512,onnxruntime在动态shape下不会直接报错,而是把数据按错误形状解释,输出变成形状错乱后的分类结果。第二种是预处理mean/std差距过大,logits整体偏向某一类,argmax全压到同一个类别上。

解决:创建输入前先打印session.GetInputTypeInfo()拿到的shape,和onnx里的输入shape逐维比对;再打印一次输出shape。预处理单独跑一张图的tensor,和Python导出的amb tensor做数值比对,偏差超过1e-5就逐个环节查。这个排查顺序屡试不爽,先看形状和数值,再看代码逻辑。

4.2 分割结果比PyTorch低几个点:预处理三处隐蔽差异

现象:同一张图,C++出的mask和PyTorch出的mask肉眼看着很像,但逐像素IoU比训练时低2到5个点,找半天不知道差在哪。

原因:resize插值算法不一致、BGR与RGB通道顺序没转、mean/std取值不对,或者忘了除以255。这三处翻车率几乎各占三分之一,而且它们叠加后的误差不是线性的,可能某处细节完全丢失。

解决:做一个预处理对照实验。在Python端把一张图经过训练transform后的float tensor保存成bin文件,C++端读同一张图、跑同一个preprocess函数,也把tensor dump成bin,用Python逐元素对比。差异定位到具体是resize还是归一化,几行代码的事。另外注意,如果输出要做置信度阈值过滤,必须先做softmax再argmax;如果只是纯argmax,softmax是单调函数,结果一致,很多人漏掉也不会暴露。

4.3 Windows部署缺DLL:运行库和onnxruntime库的发布路径

现象:开发机上跑得好好的exe,拷到目标Windows机器双击,弹窗报"找不到VCRUNTIME140.dll"或者"找不到onnxruntime.dll"。

原因:开发机装着完整Visual Studio和VC运行时,目标机没有;onnxruntime预编译包是动态链接的,dll没有跟着exe一起走。本地部署AI模型的Windows工程,十个有八个栽在这里。

解决:发布时把onnxruntime.dll放到exe同目录;干净机器上装一遍microsoft visual c++ 2015-2022 redistributable (x64),这几乎是Windows下C++部署的默认前置条件。嫌麻烦就在CMake里把onnxruntime静态库链接进来,但静态库要自己找对应版本编译,代价更高。另外检查预编译包是x64还是x86,目标机和编译选项都要是x64,混了会报"应用程序无法正常启动"。

4.4 性能瓶颈不在推理:内存拷贝、线程数和预热

现象:给Session加了线程数之后fps没变化,甚至变慢;换一张更大图时明显卡顿一下;CPU占用率看着很高但一帧还是要一百多毫秒。

原因:时间都花在预处理HWC转CHW的循环拷贝上,或者onnxruntime内部arena内存和系统内存反复交换。多线程时CPU上下文切换也很严重,intra_op线程数和主流程线程抢核。

解决:先做分段时间统计,很多项目的瓶颈根本不在session.Run,而在resize和通道转换。把预处理循环改成指针增量访问,减少at()调用;还可用cv::dnn::blobFromImage替代手写循环,但注意归一化参数要重新对好。SetIntraOpNumThreads不要设成全部核数,工控机常见4核,设2或3反而更稳定。如果用了CUDA provider,CPU和GPU之间的H2D拷贝也要纳入统计,这部分经常是隐藏开销。

4.5 上FP16或INT8后精度崩:LiteSeg的分割头不抗量化

现象:把onnxruntime的CUDA provider打开并启用FP16,或把模型量化成INT8后,mask明显出现碎点和带状噪声,mIoU从70掉到55,直接没法用。

原因:分割任务输出是所有像素的密集分类,logits的数值动态范围比分类网络大;FP16尾数精度不足,INT8量化如果校准集选得不充分,背景类的动态范围会压掉前景小目标。

解决:先用FP32把全流程跑通,把逐类IoU记录下来作为基线。FP16要过同一验证集的对比验收再决定要不要上。INT8量化一定要用训练分布接近的校准集,遥感图像语义分割和城市道路场景的校准集不能混用。对LiteSeg这种轻量网络,部署收益主要来自图优化和线程调度,FP16/INT8的收益没那么大,精度掉点超过1个点我一般直接放弃量化,保持FP32。

5. 让LiteSeg在C++里跑得更快:三个验证和调优技巧

第一个技巧是线程数不要拉满。4核工控机上把intra_op设为3,8核设6,留一个核给系统和其他线程,吞吐反而更稳。代码就两行:

session_opts.SetIntraOpNumThreads(3); session_opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);

第二个技巧是预热。ONNX Runtime第一次Run会做图优化和内存池分配,启动后第一帧往往比后续帧慢一个数量级。正式循环前用同一尺寸的随机tensor跑一次空Run,把这部分开销提前吃掉:

std::vector<float> warmup_tensor(3 * 512 * 1024, 0.f); Ort::Value warmup_input = Ort::Value::CreateTensor<float>( memory_info, warmup_tensor.data(), warmup_tensor.size(), input_shape.data(), input_shape.size()); session.Run(run_opts, input_names, &warmup_input, 1, output_names, 1);

第三个技巧也是最容易被跳过的:精度验收。项目里保留一个diff脚本,C++端把logits dump到bin文件,Python端加载同样的onnx跑同一张图,逐元素对比。fp32下最大绝对误差应小于1e-3,mask一致率应接近100%。后面每次改后端、改预处理、开图优化,都跑一遍这个对比,超阈值立刻报警。我在之前的项目里翻过车,最后发现不是算子实现问题,而是预处理少了一步除方差。现在我的习惯是项目第一天就把diff脚本写出来,后面每一次动模型、动后端、动归一化参数,都回去跑一遍对照,用数据说话。多花的一个小时,等于给上线买了份后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询