ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

图像分割模型:云端训练与Python本地推理实战

图像分割模型:云端训练与Python本地推理实战 前段时间接了一个户外广告牌监测的活儿要自动识别街景图里的广告牌区域统计牌面大小和形状。我一开始想得太简单以为把开源的UNet搬回来改吧改吧就能跑结果笔记本只有核显训练一个像样的分割模型动辄十几个小时数据量稍微大一点直接卡死根本没法迭代。后来我把训练环节放到了百度智能云上用平台的图像分割模型训练能力把模型训出来再导出模型文件用Python在本地加载模型并对图像做分割推理。这条“云端训练本地推理”的链路我完整跑通过从数据标注到最终输出标记好的图片整个过程比想象中顺但坑也真的不少。这篇文章就把这套流程拆开讲清楚尤其后面用Python调用pb模型的部分会尽量给出能直接复用的代码和排查思路。1. 为什么我会用云端训练来解决图像分割1.1 本地训练图像分割模型的现实门槛图像分割和图像分类不一样它输出的不是“这是什么”而是“每个像素属于哪一类”。这意味着标签是一张和原图一样大的mask图模型要学的是像素级的映射关系。就拿广告牌分割来说街景图里广告牌的形状、光照、遮挡、透视变形千奇百怪模型需要的数据量动辄上千张每张图要逐像素标注。数据量大带来的第一个问题就是显存。UNet这类分割模型输入分辨率如果到512×512单卡显存低于6GB基本跑不动像样的batch size。就算用小batch硬跑一个epoch要遍历几百上千张图没有GPU的话训练一轮下来能把人熬疯。我当时用CPU试跑过一个mini版UNet300张图训练50轮大概跑了十几个小时出来效果还很一般。这就是典型的“时间成本换空间成本”很不划算。1.2 云端平台替你省掉的到底是什么百度智能云这类平台解决的核心问题不是把训练算法变神奇而是帮你把“环境搭建、GPU资源、训练调度、模型管理”这几件脏活累活全包了。那段时间我用平台的图像分割项目功能几行配置就能开起训练任务平台自动分配GPU资源训练过程中还能实时看loss曲线。对我来说省下的是本地装驱动、配CUDA、调显存OOMOut of Memory的时间这些时间省下来可以全部花在数据标注和模型调参上。这里我特别想强调一点上云不等于一劳永逸。你上传的数据、标注质量、类别定义仍然决定模型上限。平台只是提供了一个更顺手的训练环境数据侧的功夫一点都不能少。1.3 什么样的项目不建议走云端云端训练不是银弹。如果你的项目涉及敏感数据不允许出本地那就不适合。另外如果你的任务非常简单比如只有二分类、目标很小、样本量几百张那本地用预训练模型微调就够了犯不上折腾上传数据和训练调度。还有如果只是做一次性的模型验证也可以先用公开的预训练权重在本地跑通流程再决定要不要上云。我的判断标准很简单项目需要反复迭代模型、数据量中等偏上、本地没有可用的GPU这三个条件满足两个就值得用云端训练。广告牌分割项目三条都占了所以方向我选得很肯定。2. 在训练平台上把模型训到这个状态再导出2.1 建项目和标注数据时容易被忽略的规范用平台训练图像分割模型第一步是创建数据集。我当时建的是“图像分割”类型的项目数据标注用平台提供的在线标注工具沿着广告牌边缘打多边形画完一个目标就分配一个类别标签。这里有几个细节建议第一次上手的同学特别注意类别顺序在标注工具里就已经定死了。后续导出的标签文件顺序和训练时的类别顺序是一致的。如果你先建了“背景”类别再建“广告牌”导出后索引0就是背景、索引1就是广告牌。这个顺序在后边Python推理时会影响颜色映射一定提前定好不要乱改。最小标注数量不要硬凑。平台一般会给出最少标注张数的建议比如每类至少几十张但这个数量只是“能跑通”离“效果好”还差得远。广告牌的形态差异很大我只标了两百张效果就很一般后来补充到六百张左右误差明显下降。如果样本太少优先考虑用数据增强来缓解过拟合。边缘要尽量贴合。在线标注工具画多边形点不要打太稀尤其是弧形广告牌。mask边界不准确模型学到的边缘就是糊的推理出来的分割结果会自带一圈锯齿或膨胀。标注虽然费时间但这是整个项目里性价比最高的投入。2.2 训练参数到底该怎么填训练任务创建时需要选择算法模型和训练配置。不同平台的具体字段名称可能不一样但核心就这几项模型结构/算法类型一般会提供高精度和高性能等选项。高精度版本准确率更高但推理速度会慢一点高性能版本更轻量。如果模型最终要部署到服务器或边缘盒子需要结合硬件性能选。我第一版选的高精度后来发现推理耗时有点高就换成了轻量的版本精度掉了不到1%但速度提升了差不多一倍。输入分辨率这个参数非常关键。训练分辨率决定了模型能“看到”的细节。分辨率越大对小广告牌的拟合能力越强但训练和推理时间都会明显增加。我实际选择是512×512既能识别中等大小的广告牌速度也能接受。训练轮数/迭代次数平台一般会让填最大训练轮数。我可以给一个经验值不要盲目追求大轮数当验证集的评估指标不再提升时再多训练几乎没有收益。平台通常会保存效果最好的那一版模型不需要自己手动挑。2.3 怎么看懂模型评估指标而不是只看loss训练结束之后平台会给出评估指标。大多数平台都会展示mIoU平均交并比或IoU交并比。这俩指标比loss直观得多它衡量的是预测分割区域和真实标注区域的重合程度。先记住一个结论mIoU超过0.75算是一个可以试用的分割模型低于0.6基本没法直接用于业务需要补数据或调参。我看评估结果的时候除了看整体mIoU还会重点看每个类别的IoU。广告牌分割只有两个类别还好当类别数变多时个别类别IoU很低会被平均值掩盖。某类IoU明显低通常意味着这类样本数量不足或形态太复杂需要单独补充数据。3. 导出模型时为什么我非要选pb格式3.1 EasyDL导出成不同格式本质有什么区别模型训练完成后平台一般支持导出成多种格式常见的有TensorFlow格式、PaddlePaddle格式、OpenVINO格式等。这些格式服务于不同的推理环境导出格式主要推理环境特点TensorFlowpb/SavedModelPython TensorFlow生态成熟跨平台适合自建服务PaddlePaddlePaddlePaddle框架国产深度学习框架服务化能力强OpenVINOIntel CPU/GPU/VPU推理优化好适合Intel硬件部署硬件特定格式边缘盒子、嵌入式设备与具体芯片绑定优化充分但不通用我当时选了TensorFlow格式主要是因为后续要用Python调用TensorFlow的生态最成熟资料多遇到问题也容易搜。而这里最关键的其实是“pb文件”也就是TensorFlow的模型图文件。Python加载pb文件做推理是这条链路里最需要动手写代码的部分。3.2 pb模型文件的真实结构pb格式和常见的h5、pt格式有本质区别。h5和pt保存的是模型结构和权重加载之后就是一个完整的模型对象可以直接调用。pb文件保存的是序列化之后的计算图你可以理解成把“数据流图所有参数”打包成了一个文件。加载pb文件时其实是在内存里重新还原这张计算图然后用数据去喂它。在代码层面的一个明显体现是加载h5模型后可以直接用model.predict(x)加载pb模型后你需要找到计算图的输入张量名和输出张量名用Session会话去run。对新手来说这是第一个会有心理落差的地方。3.3 导出前后必须抄下来的“接口信息”导出pb模型之后不要急着写代码。先确认三件事这三件事就是Python调用的“接口说明书”模型输入张量名常见命名可能是image_tensor:0或input:0。名字不对代码直接报错。模型输出张量名分割模型的输出一般是每个像素的类别得分常见命名可能是logits:0、mask:0或output:0。输入尺寸和归一化方式模型要求输入多大分辨率像素是除以255还是归一化到-1到1之间这些信息平台文档或模型包里通常会有说明。我建议你把这三项直接记在项目README里。后边写Python脚本时所有参数都可以通过命令行传入不用每次改代码也不会因为换模型而把逻辑写死。4. Python端加载pb模型的完整实现4.1 环境搭建里最容易翻车的地方Python调用pb模型最稳的方式是使用TensorFlow的图加载机制。这里有一个典型版本坑很多pb模型是用TensorFlow 1.x导出的而现在大家pip安装的默认是2.x。如果你直接装最新版TensorFlow然后按照老教程写代码大概率会遇到Session不存在、placeholderAPI改变这类问题。我的解决方案是安装TensorFlow 2.x然后用它的兼容模块tf.compat.v1来写加载逻辑。这样既能利用新版库又能兼容1.x风格的计算图操作。安装命令很简单pip install tensorflow-cpu如果你不需要GPU推理尽量安装CPU版本体积小依赖少不容易出问题。我给一个环境建议Python 3.8到3.10范围内都行TensorFlow版本用2.10或相近版本这是兼容性和稳定性都不错的组合。4.2 加载计算图的两种方式和我的选择在TensorFlow 2.x里加载pb模型常见做法有两种用tf.saved_model.load()加载SavedModel目录。用tf.compat.v1.GraphDef()解析pb文件然后导入到默认计算图。我推荐第二种原因是很多导出的pb文件就是单纯的冻结图freeze graph不带SavedModel的签名信息用tf.saved_model.load()不一定能顺利拿到输入输出。而GraphDef方式直接把整个计算图加载进来通过张量名指定输入输出适配面广代码逻辑也清晰。具体加载代码import tensorflow.compat.v1 as tf # 关闭TensorFlow 2.x默认的Eager模式切换到图模式 tf.disable_v2_behavior() def load_pb_graph(pb_path): graph tf.Graph() with graph.as_default(): graph_def tf.GraphDef() with tf.gfile.GFile(pb_path, rb) as f: graph_def.ParseFromString(f.read()) tf.import_graph_def(graph_def, name) return graph graph load_pb_graph(./model/your_model.pb)如果你在加载时不确定模型里的输入输出节点名可以在Session创建后打印所有节点with tf.Session(graphgraph) as sess: ops graph.get_operations() for op in ops: print(op.name, op.outputs)这一步非常有用。当你拿到打印结果后找到形状是[None, height, width, 3]的输入张量以及形状是[None, height, width, num_classes]的输出张量就能确定节点名。4.3 图像预处理不只resize那么简单加载pb模型之后数据预处理往往是出错最多的地方。图像分割的预处理链路至少包括四步读取图片、通道转换、尺寸缩放、归一化。import cv2 import numpy as np def load_and_preprocess(image_path, input_h, input_w): # 用OpenCV读图读出来是BGR顺序 img_bgr cv2.imread(image_path) # 大多数分割模型训练用的是RGB需要转换通道 img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) # 缩放尺寸插值方式选双线性不要用最近邻 img_resized cv2.resize(img_rgb, (input_w, input_h), interpolationcv2.INTER_LINEAR) # 归一化到0~1 img_norm img_resized.astype(np.float32) / 255.0 # 添加batch维度从(H, W, 3)变成(1, H, W, 3) input_batch np.expand_dims(img_norm, axis0) return input_batch这里为什么强调双线性插值因为最近邻插值会直接丢掉像素过渡信息分割结果很容易出现锯齿和边缘错位。训练时平台用的基本也是双线性这类的平滑插值推理时保持一致才能让数据分布接近。归一化方式务必和你确认到的模型要求保持一致。有的模型是除以255有的是减均值除方差有的甚至转成0~255后原样输入。这一项错了模型输出的概率分布会乱掉表现就是分割结果完全错乱或者大面积全背景。4.4 执行推理和后处理染色模型推理这一步核心就一行session.run但前后处理要跟上。def run_inference(graph, input_batch, input_node, output_node): with tf.Session(graphgraph) as sess: input_tensor graph.get_tensor_by_name(input_node) output_tensor graph.get_tensor_by_name(output_node) logits sess.run(output_tensor, feed_dict{input_tensor: input_batch}) return logits分割模型的输出通常是(1, height, width, num_classes)的得分矩阵下一步需要把它变成每个像素一个类别编号的mask# 去掉batch维度变成(height, width, num_classes) probs logits[0] # 沿着最后一个维度取最大值下标得到(height, width)的类别索引 mask np.argmax(probs, axis-1).astype(np.uint8)有了mask就可以染色并叠加原图生成可视化结果def visualize_result(image_path, mask, save_path, colors): img_bgr cv2.imread(image_path) h, w mask.shape img_bgr cv2.resize(img_bgr, (w, h)) # 构建彩色mask color_mask np.zeros((h, w, 3), dtypenp.uint8) for class_id, color in enumerate(colors): color_mask[mask class_id] color # 原图与mask叠加 overlay cv2.addWeighted(img_bgr, 0.6, color_mask, 0.4, 0) cv2.imwrite(save_path, overlay)这里有个小细节OpenCV画图用的是BGR顺序所以自定义颜色列表时红色要写成(0, 0, 255)而不是(255, 0, 0)。我第一次没注意所有颜色反过来了排查了半天。4.5 一份可以直接跑通的完整脚本把前面的逻辑整合起来加上命令行参数就是一份可以直接套用的脚本# -*- coding: utf-8 -*- import argparse import cv2 import numpy as np import tensorflow.compat.v1 as tf tf.disable_v2_behavior() def load_graph(pb_path): graph tf.Graph() with graph.as_default(): graph_def tf.GraphDef() with tf.gfile.GFile(pb_path, rb) as f: graph_def.ParseFromString(f.read()) tf.import_graph_def(graph_def, name) return graph def preprocess(image_path, input_h, input_w): img_bgr cv2.imread(image_path) if img_bgr is None: raise ValueError(图片读取失败请检查路径 image_path) img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) img_resized cv2.resize(img_rgb, (input_w, input_h), interpolationcv2.INTER_LINEAR) img_norm img_resized.astype(np.float32) / 255.0 return np.expand_dims(img_norm, axis0) def main(args): graph load_graph(args.model) input_batch preprocess(args.image, args.height, args.width) with tf.Session(graphgraph) as sess: input_tensor graph.get_tensor_by_name(args.input_node) output_tensor graph.get_tensor_by_name(args.output_node) logits sess.run(output_tensor, feed_dict{input_tensor: input_batch}) probs logits[0] mask np.argmax(probs, axis-1).astype(np.uint8) # 类别颜色BGR顺序按训练时的类别顺序排列 colors [ (0, 0, 0), # 背景黑色 (0, 255, 0), # 第一类绿色这里按你自己的类别调整 ] img_bgr cv2.imread(args.image) img_bgr cv2.resize(img_bgr, (args.width, args.height)) color_mask np.zeros((args.height, args.width, 3), dtypenp.uint8) for class_id, color in enumerate(colors): color_mask[mask class_id] color overlay cv2.addWeighted(img_bgr, 0.6, color_mask, 0.4, 0) cv2.imwrite(args.output, overlay) print(分割结果已保存到:, args.output) if __name__ __main__: parser argparse.ArgumentParser(descriptionPython调用pb图像分割模型) parser.add_argument(--model, requiredTrue, helppb模型文件路径) parser.add_argument(--image, requiredTrue, help待分割图片路径) parser.add_argument(--output, default./result.png, help结果保存路径) parser.add_argument(--height, typeint, default512, help模型输入高度) parser.add_argument(--width, typeint, default512, help模型输入宽度) parser.add_argument(--input_node, defaultimage_tensor:0, help输入张量名) parser.add_argument(--output_node, defaultlogits:0, help输出张量名) cargs parser.parse_args() main(cargs)运行方式python segment_image.py \ --model ./model/your_model.pb \ --image ./test.jpg \ --output ./result.jpg \ --height 512 \ --width 512需要注意这里input_node和output_node的默认值是我假设的常见命名。你实际使用时一定要改成你导出的模型里真实的节点名可以通过前面说的打印节点方式确认。5. 我踩过的几个坑和完整的排查思路5.1 明明加载成功输出却是一堆NaN或全0我第一次跑通脚本满心期待结果打开的mask图一片漆黑全是背景类。输出logits里确实有数值但非常极端几乎都集中在一个类别上另外几个类别全是特别小的负数。排查思路先打印logits的统计信息比如最小值、最大值、均值看看数值范围和量级。如果明显异常优先怀疑预处理和训练时有差异。我当时差在归一化上——平台训练时的归一化方式不是简单除以255而是先除以255再做了标准化也就是减均值除方差。模型在训练时习惯了那种数值分布我用的是0到1的分布去喂输出自然错乱。解决方式也很直接把预处理改成和训练一致。平台导出模型时通常会附带一份说明里面写了输入数据的预处理要求照着改就行。5.2 输出mask和原图对不上错位、镜像、翻转这个坑特别隐蔽。有一次分割出来效果很好但叠加到原图上发现整体位置偏移有的方向甚至镜像。问题出在图像读取顺序和resize时机。OpenCV读图默认是BGR如果模型训练用RGB通道顺序不转分割出来的是基于错误颜色空间的预测但形状大概还在看起来就很怪。resize如果没等比缩放长宽比变了分割边缘会和原图错位。我的处理方式是在后处理阶段把原图resize到和mask一样的尺寸然后再叠加两边尺寸统一避免错位。另外还有一种情况是图像有EXIF旋转信息。手机拍的图如果带着旋转方向cv2.imread默认不识别EXIF读进来可能是横着的。用PIL读图再转回numpy可以规避或者统一在预处理阶段旋转到位。5.3 节点名找不到、报key不存在的处理流程运行graph.get_tensor_by_name(xxx:0)时如果报KeyError说明你写的节点名和模型里的不一致。这时候不要瞎猜按下面的流程排查查看导出目录有没有说明文档或配置文件里面通常写了输入输出节点名。用4.2节里的方法打印所有节点名搜索包含input、image、logits、mask、output等关键字的节点。注意节点名末尾的:0这是张量的第0个输出不要漏掉。如果看到的是类似import/xxx:0的前缀说明导入计算图时带了name前缀。我代码里用的是name一般不会额外加前缀。如果你改成其他name张量名也会变。5.4 类别颜色错乱训练时的顺序坑分割结果出来了但颜色对应关系不对广告牌显示成背景色背景显示成广告牌色。这个问题不是模型推理错误而是类别顺序没对齐。我在2.1节里强调过数据标注时确定的类别顺序就是模型输出的顺序。假设训练标签顺序是索引0背景、索引1广告牌那么argmax得到的mask里值为1的地方才是广告牌。你染色时如果用mask 0去映射广告牌颜色自然全错。我的建议是在标签文件labels.txt出来之后第一时间写一个小脚本验证顺序用一张已经标注过的测试图跑一遍推理核对预测mask和真实mask的逐类IoU。哪类IoU高就说明哪类对齐了不用靠肉眼猜颜色。6. 从能跑到能用分割结果业务化6.1 把mask转换成面积、外接矩形、圆度等特征模型跑通只是第一步实际业务往往需要从分割结果里提取量化指标。广告牌监测的需求是统计每个广告牌的牌面面积、外接矩形、形状规则程度。使用OpenCV的连通域分析可以很容易完成# mask中值为1的像素视为目标 binary_mask (mask 1).astype(np.uint8) num_labels, labels, stats, centroids cv2.connectedComponentsWithStats(binary_mask, connectivity8) for label_id in range(1, num_labels): x, y, w, h, area stats[label_id] # 外接矩形 cx, cy centroids[label_id] print(目标, label_id, 面积:, area, 外接矩形:, (x, y, w, h)) # 求圆度轮廓面积与最小外接圆面积之比 contour_mask (labels label_id).astype(np.uint8) contours, _ cv2.findContours(contour_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: cnt contours[0] (center_x, center_y), radius cv2.minEnclosingCircle(cnt) circle_area np.pi * radius * radius circularity area / circle_area if circle_area 0 else 0 print(目标, label_id, 圆度:, round(circularity, 3))圆度接近1表示目标接近圆形广告牌这类矩形目标一般圆度会在0.6到0.8之间。这类量化特征可以直接喂给下游业务系统做筛选排序。6.2 批量推理时的batch和内存控制单张推理够用之后批量处理会暴露新问题。内存占用是最大的瓶颈。分割模型输出一个512×512×2的float张量单张也就2MB左右但如果你一次塞进去64张图片batch维度会放大64倍内存消耗会明显上涨。在CPU推理场景下我建议batch size设置为1或者4再大收益不明显反而容易把内存占满。另一个优化点是输入尺寸。如果业务不关注小目标完全可以把resize尺寸从512降到384或256推理速度会成倍提升。代价是分割边缘会粗糙一点但大部分业务场景足够用。这个平衡需要根据实际结果去试。6.3 扩展思路包一层HTTP服务给其他业务调用如果你不满足于命令行脚本还想把这个分割能力提供给其他业务方调用可以用Flask或FastAPI包一层HTTP接口。我之前就把这个脚本封装成了推理服务客户端上传图片服务端返回分割mask的base64、目标数量、外接矩形列表和圆度特征整个调用链路干净利落。from flask import Flask, request, jsonify import base64 import cv2 import numpy as np app Flask(__name__) graph load_graph(./model/your_model.pb) # 全局加载一次避免每次请求都重新加载模型 app.route(/segment, methods[POST]) def segment(): file request.files.get(image) if file is None: return jsonify({error: no image}), 400 img_bytes np.frombuffer(file.read(), np.uint8) img_bgr cv2.imdecode(img_bytes, cv2.IMREAD_COLOR) input_batch preprocess_from_cv_img(img_bgr, 512, 512) with tf.Session(graphgraph) as sess: input_tensor graph.get_tensor_by_name(image_tensor:0) output_tensor graph.get_tensor_by_name(logits:0) logits sess.run(output_tensor, feed_dict{input_tensor: input_batch}) mask np.argmax(logits[0], axis-1).astype(np.uint8) # 这里继续做连通域分析、特征提取等然后返回JSON return jsonify({segmentation: success, mask_shape: mask.shape})这样整个流程就从一个离线脚本变成了一个可被复用的服务Python调用模型的成本也能被其他语言封装后间接复用。根据我个人的实际操作体验这套云端训练加Python调用的方案最大的价值在于把模型训练的硬性门槛转移到云上而把灵活调用留在本地。真正耗时间的不是写代码而是数据标注和调试预处理。建议你第一次跑通时尽量把所有参数做成命令行参数给模型预留出一份接口说明文档后面换模型、调参数都会省很多事。
返回列表