
简介面向需要在 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 和 signaturevariables/下是权重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, nameinput) ]) def serving(x): return {seg_mask: model(x, trainingFalse)} tf.saved_model.save( model, export/unet_savedmodel, signatures{serving_default: serving} )input_signature里的name会映射成实际 tensor 名C 侧就是靠这个名字取数batch 维写成None是为了让同一份模型兼容 batch 为 1 和 batch 为 4 的调用trainingFalse是关键不关的话 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_cc和tf_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:0、output: serving_default_seg_mask:0后面 Run 时就拿这两个字符串。把打印出来的名字和 Python 侧saved_model_cli show的结果对上就能确认导出和加载是一套东西。3.3 从 cv::Mat 构造输入 Tensortensorflow::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.tensorfloat, 4(); for (int i 0; i h; i) { for (int j 0; j w; j) { const cv::Vec3f px rgb_f32.atcv::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 布局tensorfloat, 4的索引顺序就是batch, height, width, channel和 OpenCV 的atVec3f(i, j)正好对位。如果cv::Mat的内存是连续的即step cols * 3 * sizeof(float)可以跳过逐元素循环直接memcpy(t.flatfloat().data(), f32.data, ...)但 OpenCV 有些操作会引入行对齐 padding稳妥做法是逐行拷性能差异在 256 尺寸下可以忽略。3.4 Run 执行推理std::vectorstd::pairstd::string, tensorflow::Tensor inputs { {input_name, input_tensor} }; std::vectortensorflow::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必须在进模型之前做训练时喂的是 RGBC 侧用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/copyGC 压力就小一个量级。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.tensorfloat, 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.atuchar(i, j) static_castuchar(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.atcv::Vec3b(i, j) palette[mask.atuchar(i, j)];提示医学图像分割场景里argmax 之后通常还要做连通域过滤。cv::connectedComponentsWithStats去掉面积小于阈值的孤立区域放在 resize 回原图之前做计算量最小。4.4 性能参数与 warmup分割服务上线前必须调一组运行时参数别用默认值裸奔参数建议值说明intra_op_parallelism_threads4 ~ 8与物理核数挂钩过大延迟反而上升inter_op_parallelism_threads0默认自动单算子为主的场景影响小allow_growthtrueGPU 服务避免一上来占满显存batch1 或 4延迟敏感用 1吞吐优先逐步拉到 4warmup 次数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].flatfloat().data(); size_t n outputs[0].NumElements(); std::ofstream f(cpp_out.f32, std::ios::binary); f.write(reinterpret_castconst 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 大版本模型重新导出或重编 libSession 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 侧预处理后的输入对比这一步能直接把问题切到预处理错还是推理错。十次里九次是预处理细节通道顺序、归一化、插值方式各占一席。本文还有配套的精品资源点击获取