news 2026/9/13 20:54:50

TensorFlow C++ 图像分割部署:从 SavedModel 导出到高效推理实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TensorFlow C++ 图像分割部署:从 SavedModel 导出到高效推理实践

简介:面向需要在 C++ 工程中直接调用 TensorFlow 库完成图像分割的开发者,这份资源专门整理了与常见图像分类不同的调用方法,避免在张量维度、输入输出处理上反复踩坑。分类任务通常输出一个类别标号,而分割需要逐像素预测,C++ 接口在数据排布、会话参数和结果解析上差别明显,作者在查找很久后总结出的这套流程可直接参考。压缩包共53个文件、约29.05MB,主体为48张png图像,用于展示输入原图、分割结果与中间过程;另含2个cpp源码、1个h头文件、1个pb模型和1个tiff测试图,构成完整的最小可复现示例。资源以UNet分割模型为例,演示了C++端加载pb模型、创建会话、执行前向推理的完整链路,并将源码、输入图、结果图与测试素材分目录存放,便于对照修改。附带的预训练模型可直接在测试图上推理,帮助理解图像分割中像素级输出张量的解析方式。目前已有222人学习下载,适合有一定C++基础、希望脱离Python环境集成TensorFlow图像分割能力的开发者,可快速迁移到自己的项目中。

1. TensorFlow C++ 图像分割:训练归 Python,上线归 C++

同一套 UNet 分割模型,训练时 Python 里怎么跑都行,一旦线上要求单帧推理低于 20ms、进程里不能带解释器、内存占用可控,落点基本都在 TensorFlow C++。TensorFlow C++ 图像分割指的是在 C++ 进程里加载 SavedModel、构造输入 Tensor、执行 Session::Run、再解析输出张量的整条链路,它解决的不是训练而是部署。这几年 PyTorch 在训练侧越来越流行,但生产侧 TensorFlow C++ 的存量部署和工具链依然大量存在,很多团队手里就是一份训练好的分割模型,卡在 C++ 侧调不通。这套流程适合两类人:要把 UNet、DeepLab 这类模型部署到服务端或嵌入设备的工程师,以及被线上性能逼着去掉 Python 解释器的后端团队。下面从模型导出开始,给出一条能直接复现的最小路径。

2. C++ 图像分割第一步:SavedModel 导出与链接环境

C++ 侧没有 Keras 层,也没有 Python 的对象图,模型必须先在 Python 侧固化成 SavedModel。这一步做错,后面所有代码都白写。

2.1 为什么不直接读 checkpoint 或 h5 文件

checkpoint 只是权重值,不包含图结构,C++ 侧没法凭空把层拼回来;h5 需要 Keras 解析器,而 TensorFlow C++ 的公共 API 不提供这条路。SavedModel 是自包含的:saved_model.pb里固化了 GraphDef 和 signature,variables/下是权重,assets/存辅助资源,拷走整个目录就能加载。

TF 1.x 时代常见做法是用freeze_graph把权重冻结进 pb,但冻结图丢失了 signature 信息,C++ 侧取输入输出只能靠手写 op 名,容易错。TF 2.x 直接用tf.saved_model.save带 signature 导出,C++ 侧按名字稳定取张量,这是当前最可靠的做法。

2.2 Python 侧导出代码与 signature 设计

导出用的 Python 环境用 Anaconda 安装 TensorFlow 就好,版本与 C++ 侧libtensorflow_cc对齐到同一个大版本,小版本差异通常能容忍,但 2.x 和 1.x 绝对不能混用。导出代码:

import tensorflow as tf model = tf.keras.models.load_model("unet_256.h5") @tf.function(input_signature=[ tf.TensorSpec([None, 256, 256, 3], tf.float32, name="input") ]) def serving(x): return {"seg_mask": model(x, training=False)} tf.saved_model.save( model, "export/unet_savedmodel", signatures={"serving_default": serving} )

input_signature里的name会映射成实际 tensor 名,C++ 侧就是靠这个名字取数;batch 维写成None是为了让同一份模型兼容 batch 为 1 和 batch 为 4 的调用;training=False是关键,不关的话 dropout 和 BatchNorm 的训练分支会被保留,推理结果和离线评测对不上。导出后用saved_model_cli验证 signature 是否齐全:

saved_model_cli show --dir export/unet_savedmodel \ --tag_set serve --signature_def serving_default

能看到 inputs 和 outputs 的 key、dtype、shape,说明导出成功;看不到任何输出,说明模型保存时没带 serving 签名。

2.3 CMake 链接 libtensorflow_cc 的最小工程

C++ 侧依赖两个库:libtensorflow_cc.so提供会话和图执行,libtensorflow_framework.so提供算子注册和基础设施。只链前者的后果是一堆 undefined reference。最小 CMake 工程:

cmake_minimum_required(VERSION 3.16) project(seg_cpp) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(TF_ROOT "/opt/tensorflow") # libtensorflow_cc.so 所在目录 find_package(OpenCV REQUIRED) add_executable(seg_demo main.cpp) target_include_directories(seg_demo PRIVATE ${TF_ROOT}/include ) target_link_directories(seg_demo PRIVATE ${TF_ROOT}/lib) target_link_libraries(seg_demo PRIVATE ${OpenCV_LIBS} tensorflow_cc tensorflow_framework dl ) target_link_options(seg_demo PRIVATE -Wl,-rpath,${TF_ROOT}/lib)

tf_cctf_framework两个库都要在链接列表里;-Wl,-rpath让运行时不依赖LD_LIBRARY_PATH也能找到 so,部署时少一类环境问题;OpenCV 只负责图像读取和预处理,和 TensorFlow 无关,但分割流水线基本离不开它。

libtensorflow_cc.so要么自己用 Bazel 编,官方标准命令是bazel build -c opt //tensorflow:libtensorflow_cc.so,要么用社区预编译包。预编译包省时间,但要确认 GCC ABI 一致,Linux 下常见崩溃就是编包的编译器和你本地的 GCC 版本不对应。常用构建产物对比如下:

产物用途备注
libtensorflow_cc.so会话、图执行、算子内核必须
libtensorflow_framework.so基础设施与注册表显式链接,别省
tensorflow/cc 头文件loader.h、saved_model_bundleinclude 指到 TF 根目录

2.4 Windows 与 Visual C++ 运行时的坑

Windows 下拿到的是tensorflow_cc.lib加一堆 dll,必须用 MSVC 编译,MinGW 去链接官方包基本都会失败。运行时如果报找不到msvcp140.dll,就是缺 Visual C++ Redistributable,装对应版本即可,这不是代码问题。

很多人喜欢用 VS Code 手写 tasks.json 编译,做演示可以,正经工程建议直接上 CMake + Ninja。VS Code 配置 C/C++ 环境时,把 include 指向 TF 头文件目录、把编译 kit 选到 MSVC,就能同时拿到补全和编译链。另外官方库是 release 编译的,你的工程切到 debug 链接它,轻则链接告警,重则运行期内存错误。

3. TensorFlow C++ 推理:SavedModelBundle 加载与 Tensor 构造

环境通了之后,核心就是把模型加载进SavedModelBundle,然后把图数据塞成Tensor。这一章讲最小可跑的推理代码。

3.1 LoadSavedModel 的参数解析

#include "tensorflow/cc/saved_model/loader.h" #include "tensorflow/core/protobuf/saved_model.pb.h" using tensorflow::SavedModelBundle; using tensorflow::SessionOptions; using tensorflow::RunOptions; bool LoadSegmenter(const std::string& export_dir, SavedModelBundle* bundle) { SessionOptions so; so.config.set_intra_op_parallelism_threads(4); so.config.set_inter_op_parallelism_threads(0); so.config.mutable_gpu_options()->set_allow_growth(true); RunOptions ro; auto status = tensorflow::LoadSavedModel(so, ro, export_dir, {"serve"}, bundle); if (!status.ok()) { LOG(ERROR) << status.ToString(); return false; } return true; }

{"serve"}是 tag set,和导出时的默认 tag 一致;bundle里装着可执行的session和完整的meta_graph_def,后者是后面取张量名的数据源。intra_op_parallelism_threads控制单个算子内部的线程数,ResizeBilinear 和 Conv2D 这类算子是主要受益者,数值一般设成物理核数的一半到满核;inter_op_parallelism_threads设 0 表示交给运行时自动决定,分割模型单算子耗时占比高,这个参数影响小但别乱设成超大值。GPU 场景必须开allow_growth,否则进程一启动就把整卡显存占满,和业务抢资源。

3.2 从 signature 拿真实 tensor 名

新手最容易翻车的地方在这:直接写serving_default_input当输入名,大概率报not found。真实原因是在 SavedModel 里我们写的 key 是逻辑名,tensor_name()才是图中实际名字,带关键字前缀和:0后缀。

std::string input_name, output_name; const auto& sig = bundle->meta_graph_def.signature_def().at("serving_default"); for (const auto& kv : sig.inputs()) if (kv.first == "input") input_name = kv.second.tensor_name(); for (const auto& kv : sig.outputs()) if (kv.first == "seg_mask") output_name = kv.second.tensor_name(); LOG(INFO) << "input: " << input_name << ", output: " << output_name;

输出类似input: serving_default_input:0output: serving_default_seg_mask:0,后面 Run 时就拿这两个字符串。把打印出来的名字和 Python 侧saved_model_cli show的结果对上,就能确认导出和加载是一套东西。

3.3 从 cv::Mat 构造输入 Tensor

tensorflow::Tensor MatToTensor(const cv::Mat& rgb_f32, int h, int w) { tensorflow::Tensor t(tensorflow::DT_FLOAT, tensorflow::TensorShape({1, h, w, 3})); auto tensor_data = t.tensor<float, 4>(); for (int i = 0; i < h; ++i) { for (int j = 0; j < w; ++j) { const cv::Vec3f& px = rgb_f32.at<cv::Vec3f>(i, j); tensor_data(0, i, j, 0) = px[0]; tensor_data(0, i, j, 1) = px[1]; tensor_data(0, i, j, 2) = px[2]; } } return t; }

Tensor 默认是 NHWC 布局,tensor<float, 4>的索引顺序就是batch, height, width, channel,和 OpenCV 的at<Vec3f>(i, j)正好对位。如果cv::Mat的内存是连续的,即step == cols * 3 * sizeof(float),可以跳过逐元素循环,直接memcpy(t.flat<float>().data(), f32.data, ...);但 OpenCV 有些操作会引入行对齐 padding,稳妥做法是逐行拷,性能差异在 256 尺寸下可以忽略。

3.4 Run 执行推理

std::vector<std::pair<std::string, tensorflow::Tensor>> inputs = { {input_name, input_tensor} }; std::vector<tensorflow::Tensor> outputs; auto status = bundle->session->Run(inputs, {output_name}, {}, &outputs); if (!status.ok()) { LOG(ERROR) << status.ToString(); return 1; } // outputs[0].shape() 应为 {1, 256, 256, num_classes}

Run的第三个参数是 target nodes,一般的分割推理用不到,传空 vector。outputs按请求顺序返回,这里只取了一个输出;需要同时拿中间特征图时,往第二个参数里追加名字即可。值得注意,Session::Run调用本身有内部锁,多个业务线程共用一个 session 不会崩,但会互相等待;并发量上来以后,常见做法是每个线程持一个SavedModelBundle,或者用 session pool,而不是魔改线程数硬扛。

4. 图像分割流水线:预处理、内存复用与 argmax 后处理

单次推理跑通只是开始,真正能用的分割服务要把读图、预处理、推理、后处理串成一条稳定链路。这一章的每一环都能直接影响分割质量。

4.1 预处理:resize、换通道、归一化的顺序不能错

cv::Mat Preprocess(const std::string& path) { cv::Mat img = cv::imread(path, cv::IMREAD_COLOR); cv::Mat resized, rgb, f32; cv::resize(img, resized, cv::Size(256, 256), 0, 0, cv::INTER_LINEAR); cv::cvtColor(resized, rgb, cv::COLOR_BGR2RGB); rgb.convertTo(f32, CV_32FC3, 1.0 / 255.0); return f32; }

三个操作顺序看着随意,实际各有讲究。cvtColor必须在进模型之前做,训练时喂的是 RGB,C++ 侧用imread读出来是 BGR,漏掉这步整个掩码都会错;convertTo的缩放系数要和训练时一致,训练用[0,1]就除 255,训练用 ImageNet 的 mean/std 就得先减均值再除方差,只除 255 会让 logits 整体漂移,边缘类别最容易乱。resize 的插值方式要和训练管线对齐,医学图像分割里很多模型训练时用最近邻,推理却用双线性,边界会多出一圈模糊的过渡带。归一化方式与后果对照如下:

训练侧归一化C++ 预处理必须做误用后果
[0,1] 线性缩放除以 255输出分布偏大,argmax 偶尔翻转
ImageNet mean/std减均值再除方差掩码大面积错,尤其小目标
未归一化(raw 值)直接转 float误除 255 后模型几乎失效

4.2 推理与内存复用

固定分辨率下,每帧都重新MatToTensor会产生重复分配。常见做法是预分配两个Tensor,一帧一帧往里面写数据,Run结束后复用outputsvector 而不是清掉重建。Run内部仍会为输出分配内存,但只要输入侧不再反复 new/copy,GC 压力就小一个量级。

batch 大于 1 时,TensorShape({N, H, W, 3})要求 N 张图排布进同一个 Tensor,这对单帧延迟没有帮助,但对吞吐有明显的提升,适合离线批处理的场景。多 batch 下注意每张图要先用相同方式 resize 到统一尺寸,分割模型不支持同 batch 内不同分辨率。

4.3 后处理:argmax 与类别着色

cv::Mat ArgMaxMask(const tensorflow::Tensor& logits, int h, int w) { auto data = logits.tensor<float, 4>(); int num_classes = logits.shape().dim_size(3); cv::Mat mask(h, w, CV_8UC1); for (int i = 0; i < h; ++i) { for (int j = 0; j < w; ++j) { int cls = 0; float best = data(0, i, j, 0); for (int c = 1; c < num_classes; ++c) { if (data(0, i, j, c) > best) { best = data(0, i, j, c); cls = c; } } mask.at<uchar>(i, j) = static_cast<uchar>(cls); } } return mask; }

模型输出往往是 logits 而不是 softmax 概率,logits 上取 argmax 和 softmax 之后取 argmax 结果完全一致,省掉一次逐元素 exp,这是分割后处理最常见的优化点。类别数从logits.shape().dim_size(3)动态取,不要写死成 2 或 10,模型只要换版本就埋雷。如果后续要把掩码贴回原图,resize 回原尺寸时必须用INTER_NEAREST,双线性会在类别边界插出灰色过渡。

着色用查表法最省事:

static const cv::Vec3b palette[8] = { {0,0,0}, {255,0,0}, {0,255,0}, {0,0,255}, {255,255,0}, {0,255,255}, {255,0,255}, {255,255,255} }; cv::Mat vis(h, w, CV_8UC3); for (int i = 0; i < h; ++i) for (int j = 0; j < w; ++j) vis.at<cv::Vec3b>(i, j) = palette[mask.at<uchar>(i, j)];

提示:医学图像分割场景里,argmax 之后通常还要做连通域过滤。cv::connectedComponentsWithStats去掉面积小于阈值的孤立区域,放在 resize 回原图之前做,计算量最小。

4.4 性能参数与 warmup

分割服务上线前必须调一组运行时参数,别用默认值裸奔:

参数建议值说明
intra_op_parallelism_threads4 ~ 8与物理核数挂钩,过大延迟反而上升
inter_op_parallelism_threads0默认自动,单算子为主的场景影响小
allow_growthtrueGPU 服务避免一上来占满显存
batch1 或 4延迟敏感用 1,吞吐优先逐步拉到 4
warmup 次数5 ~ 10抹掉首次运行的算子初始化和形状推断开销

warmup 直接在加载后空跑几轮:

for (int i = 0; i < 5; ++i) bundle->session->Run(inputs, {output_name}, {}, &outputs);

注意:压测数据里如果第一帧耗时明显偏高,多半就是没做 warmup,这不代表模型真实性能。

另外提一句,YOLO 系的图像分割模型(YOLOv8-seg 这类)输出是 mask 系数加 prototype 向量,后处理不是单纯 argmax,而是要做一个矩阵乘再加 sigmoid。这类模型在 C++ 侧通常拆成两步:推理拿原始输出,mask 解码用 Eigen 或手写循环实现,Run本身没有任何区别。

5. TensorFlow C++ 分割模型:像素级验证与运行期排错

5.1 和 Python 输出做像素级对齐

C++ 侧最怕的是"跑起来了但结果不对"。最快的定位方法不是肉眼比图,而是让 Python 和 C++ 吃同一张图,把输出 dump 成二进制文件逐像素比。

Python 侧保存输出:

import numpy as np np.array(pred).astype(np.float32).tofile("py_out.f32")

C++ 侧保存输出:

const float* p = outputs[0].flat<float>().data(); size_t n = outputs[0].NumElements(); std::ofstream f("cpp_out.f32", std::ios::binary); f.write(reinterpret_cast<const char*>(p), n * sizeof(float));

两个文件对比时看两个指标:逐元素最大绝对差,以及 argmax 后掩码的一致率。浮点结果 max diff 小于 1e-4 基本可以认定环境对齐;掩码一致率更重要,因为下游消费的是类别号不是概率。diff 偏大先查预处理,特别是归一化系数和 BGR/RGB 顺序,再查导出时training是否意外保持开启。

5.2 运行期常见报错

现象原因处理
undefined reference to tensorflow::...链接顺序错或漏了 framework 库tensorflow_cc 在前、framework 在后,两个都链
libtensorflow_framework.so: cannot open运行时找不到 so加 rpath 或用 LD_LIBRARY_PATH 指到库目录
OpKernel not found in registered opslibtensorflow 版本和模型 op 集不匹配统一 TF 大版本,模型重新导出或重编 lib
Session not created: Bad GPU deviceCUDA 版本与编译期不一致先用 CPU 版验证,再逐项对 CUDA 版本
shape [1,256,256,3] 与 [?,256,256,3] mismatchTensorShape 与 signature 不符确认 batch 维填充,别写死成 0

提示:C++ 里任何一句status.ToString()都别吞,加载失败、Run 失败、形状不合,全部打到日志里。线上排查时,第一行日志就是定位入口。

最后给一个实用习惯:把 C++ 侧的输入 tensor 在喂给Run之前 dump 成文件,和 Python 侧预处理后的输入对比,这一步能直接把问题切到"预处理错"还是"推理错"。十次里九次是预处理细节,通道顺序、归一化、插值方式各占一席。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 20:53:10

51单片机差分气压测漏仪设计与调试全指南

简介&#xff1a;本资源是一套完整的基于51单片机的测漏仪嵌入式系统设计资料&#xff0c;面向电子类专业学生、单片机初学者及硬件开发入门者&#xff0c;解决气体/液体泄漏检测类课程设计、毕业设计或小型工业监测项目落地难题。压缩包共22个文件&#xff0c;768KB&#xff0…

作者头像 李华
网站建设 2026/9/13 20:51:31

车载工控系统落地方法论:宽温实时+三防设计+量产闭环

1. 为什么“车载工控”在2026年突然成了硬通货&#xff1f;你可能刚刷到某条短视频&#xff1a;一辆矿用自卸车在零下35℃的戈壁滩上连续作业72小时&#xff0c;仪表盘无重启、CAN总线无丢帧、边缘AI识别模块持续输出障碍物热力图——弹幕飘过一句&#xff1a;“这哪是车&#…

作者头像 李华
网站建设 2026/9/13 20:50:36

ECG信号HHT时频分析与Matlab实现

1. 心电图信号时频分析的核心挑战在生物医学信号处理领域&#xff0c;心电图(ECG)信号分析一直是个经典而复杂的课题。传统ECG分析主要依赖时域特征提取&#xff08;如R波检测&#xff09;和频域变换&#xff08;如傅里叶分析&#xff09;&#xff0c;但这些方法对非平稳信号的…

作者头像 李华
网站建设 2026/9/13 20:49:55

JavaScript防抖与节流原理、实现及场景选型指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 20:48:47

uv驱动的AI Agent基础设施:解决Python依赖冲突与环境漂移

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华