焊缝识别实战:TensorFlow+OpenCV语义分割全流程解析

📅 发布时间:2026/10/11 14:11:08
焊缝识别实战:TensorFlow+OpenCV语义分割全流程解析
简介面向机器学习与工业视觉方向学习者提供一套完整的焊缝自动识别项目资料基于TensorFlow构建深度学习模型并结合OpenCV完成图像预处理、特征提取与数据增强适合想掌握从数据集构建、模型训练到评估部署全流程的毕设或工程实践人员。压缩包共27个文件大小7.52MB涵盖CNN模型代码、训练得到的pb模型与检查点数据、标注用XML文件、图像样本及项目说明等便于对照学读与实践复现。目前已有219人学习下载资源整体结构清晰核心脚本与模型文件齐备能帮助读者快速理解焊缝识别任务的实现思路也适合作为图像分类、模型导出与部署方面的参考案例。1. 焊缝识别不是滤镜为什么这个毕设方向值得动手焊缝识别在工业视觉里一直是块硬骨头。传统阈值分割在干净背景下勉强能看一旦遇到弧光、飞溅、打磨痕迹分割结果就稀碎。把这个问题交给 TensorFlowOpenCV本质上是换了一条路OpenCV 负责把图像裁到焊缝附近、做预处理TensorFlow 负责用语义分割网络判断每个像素是焊缝还是背景。这个方向的落地价值很直接——它是焊接质量检测、自动寻缝、机器人跟踪的前置步骤。适合正在选毕设题目的学生也适合想在产线上快速验证视觉方案的工程师。读完你会拿到一条能跑通的数据流水线、一个训练方案以及几个真金白银换来的坑。这里的核心思路是不要指望网络直接从大图里学会一切而是用 OpenCV 缩小问题规模再用 TensorFlow 解决像素归属这最后一个环节。2. 焊缝数据从哪来ROI裁剪、标注与tf.data流水线的搭建2.1 固定坐标ROI裁剪先把大图里没用的区域拿掉工业相机拍回来的图通常是 1920x1080 甚至更大焊缝区域往往只占画面中央一条。直接全图训练有两个问题一是背景类别占比过高加剧类别失衡二是显存浪费高分辨率小显卡根本放不进 batch。常见的做法是先用先验知识把 ROI 裁出来。焊枪位置在产线上基本固定焊缝出现的区域是稳定的所以固定坐标裁剪就够用。如果焊缝走向是斜的可以先用霍夫直线检测找到主方向旋转图像后再按固定窗口裁但那是后话先搞通主流程。import cv2 from pathlib import Path RAW_DIR Path(./raw_frames) # 原始帧目录 ROI_DIR Path(./roi_256) # 裁剪输出目录 ROI_DIR.mkdir(exist_okTrue) # 根据产线相机安装位置固定的先验裁剪出焊缝出现的区域 # 参数含义y_start/y_end 控制纵向范围x_start/x_end 控制横向范围 Y_START, Y_END 200, 800 X_START, X_END 100, 1100 for img_path in RAW_DIR.glob(*.jpg): frame cv2.imread(str(img_path)) roi frame[Y_START:Y_END, X_START:X_END] roi cv2.resize(roi, (256, 256), interpolationcv2.INTER_AREA) cv2.imwrite(str(ROI_DIR / img_path.name), roi)resize 到 256x256 是为了固定模型输入也让后续 batch 大小能稳定INTER_AREA 在缩小图像时能减少锯齿和摩尔纹。注意保存成 jpg 会带来压缩伪影如果后续要做像素级标注原图最好先存 png标注图必须存 png。没有产线相机的话用手机固定机位拍焊接视频再抽帧也能凑合但机位一定要固定视角一致性对分割模型的影响比想象中大得多换一个俯仰角之前训练好的模型很可能直接废掉。2.2 标注格式多边形标注与png mask的转换焊缝是细长条目标检测框在这里不好使标注要标到像素级。常见做法是用多边形标注工具画轮廓导出 json 后转成 png mask。一个值得强调的经验先裁剪再标注比先标注再裁剪省事得多。如果先标注再裁剪多边形坐标要跟着 ROI 原点平移出错的概率很高。我一般把图像裁成 256x256 之后直接在裁剪图上画坐标天然对齐。import json import numpy as np import cv2 from pathlib import Path def labelme_json_to_mask(json_path, img_shape(256, 256), class_id1): with open(json_path, r, encodingutf-8) as f: data json.load(f) mask np.zeros(img_shape, dtypenp.uint8) for shape in data[shapes]: if shape[label] ! weld: # 只处理焊缝类别其他标注忽略 continue pts np.array(shape[points], dtypenp.int32) cv2.fillPoly(mask, [pts], colorclass_id) return maskfillPoly 把多边形内部填充成 class_id背景保持 0。如果你在原始尺寸图上标注先建原尺寸的零矩阵填完再 resize别在坐标换算上省事。另一个习惯是标注完立刻做一次标注图叠加原图的检查焊缝本身就细多边形偏移一两个像素在视觉上几乎看不出来但训练时会给边缘带来系统性噪声。检查这一步多做一次后面少调三天 Loss。2.3 数据增强与tf.data流水线让少量样本扛住现场光照焊缝样本一般不多几十到两三百张很常见。数据增强要围绕焊接场景的真实变化做亮度扰动模拟焊接电流波动高斯噪声模拟暗光随机翻转模拟焊缝方向变化。增强不是越猛越好焊缝是强纹理结构大幅旋转会破坏方向先验左右翻转已经足够。import tensorflow as tf def augment(image, mask): # 随机左右翻转焊缝左右方向在产线上不固定 if tf.random.uniform(()) 0.5: image tf.image.flip_left_right(image) mask tf.image.flip_left_right(mask) # 亮度扰动模拟不同焊接电流下的亮度差异 image tf.image.random_brightness(image, max_delta0.1) # 对比度扰动反光场景下对比度变化剧烈 image tf.image.random_contrast(image, lower0.8, upper1.2) return image, mask def load_sample(img_path, mask_path): img tf.io.read_file(img_path) img tf.image.decode_jpeg(img, channels3) img tf.image.resize(img, (256, 256)) mask tf.io.read_file(mask_path) mask tf.image.decode_png(mask, channels1) # mask用最近邻重采样避免插值产生中间灰度值 mask tf.image.resize(mask, (256, 256), methodtf.image.ResizeMethod.NEAREST_NEIGHBOR) img tf.cast(img, tf.float32) / 255.0 mask tf.cast(mask, tf.float32) return img, mask def build_ds(img_path): mask_path tf.strings.regex_replace(img_path, roi_256, mask_256) mask_path tf.strings.regex_replace(mask_path, \\.jpg$, .png) img, mask load_sample(img_path, mask_path) return augment(img, mask) ds (tf.data.Dataset.list_files(./roi_256/*.jpg) .map(build_ds, num_parallel_callstf.data.AUTOTUNE) .shuffle(100) .batch(8) .prefetch(tf.data.AUTOTUNE))mask 用 NEAREST_NEIGHBOR 重采样是关键线性插值会在焊缝边缘产生类似 0.5 的中间值训练时网络会困惑到底算前景还是背景。normalize 到 0-1 是训练时定的规矩后面预测阶段必须保持一致这条在避坑章节里单独说。tf.data 的 shuffle、batch、prefetch 是流水线基本配置num_parallel_calls 用 AUTOTUNE 让框架自己决定线程数没必要手动填 4 或 8省心也省得在不同机器上重新调优。3. 语义分割还是目标检测TensorFlow模型选型与Loss设计3.1 为什么是语义分割细长焊缝决定了检测框不好使焊缝在图像里是细长条结构长宽比经常超过 10:1。用目标检测框去框它边界框里一半以上是背景后处理还要按角度裁剪很别扭。焊缝还可能因为反光在视觉上断成两截一个框根本框不住。语义分割把它变成像素二分类问题天然匹配焊缝在哪这个诉求。从毕设答辩角度讲分割结果的可视化也远比画一个框有说服力评审一眼就能看出模型到底学到了什么。传统视觉方案为什么不优先考虑Canny 边缘检测在焊缝和母材过渡区灰度变化平缓的地方会断开阈值分割对光照敏感弧光一亮全图都过曝。深度学习方案本质上是在学焊缝的上下文纹理而不是靠某个固定灰度阈值鲁棒性高一个量级。至于直接用大型分割模型在毕设硬件上不现实单帧推理几百毫秒产线实时性跟不上轻量网络在 CPU 上也才几十毫秒级别才有部署价值。3.2 用MobileNetV2做编码器显存友好且精度够用原版 U-Net 很经典但参数量大在入门显卡上训练慢。常见的做法是换预训练的 MobileNetV2 做编码器解码器保留 U-Net 的上采样加跳跃连接结构。编码器从 ImageNet 预训练权重起步对小数据集帮助很大。解码器每上采样一次局部细节就越少所以跳跃连接必须带上浅层特征如果显存非常紧张可以砍掉最浅那一条连接代价是焊缝边缘会粗糙一些。import tensorflow as tf from tensorflow.keras import layers def build_lightweight_unet(input_shape(256, 256, 3), num_classes1): # 编码器用ImageNet预训练权重重点是浅层特征不动高层微调 base tf.keras.applications.MobileNetV2( input_shapeinput_shape, include_topFalse, weightsimagenet ) # 取三个尺度的特征后面做跳跃连接 # block_2_add: 128x128保存焊缝边缘细节 # block_5_add: 64x64保存局部纹理 # block_13_expand_relu: 32x32保存语义信息 skips { block_2_add: base.get_layer(block_2_add).output, block_5_add: base.get_layer(block_5_add).output, block_13_expand_relu: base.get_layer(block_13_expand_relu).output, } x base.get_layer(block_16_project).output # 16x16 编码器最终特征 # 解码器从16x16逐级上采样回256x256 x layers.Conv2D(256, 3, paddingsame, activationrelu)(x) x layers.UpSampling2D(size(2, 2), interpolationbilinear)(x) # 32x32 x layers.Concatenate()([x, skips[block_13_expand_relu]]) x layers.Conv2D(128, 3, paddingsame, activationrelu)(x) x layers.UpSampling2D(size(2, 2), interpolationbilinear)(x) # 64x64 x layers.Concatenate()([x, skips[block_5_add]]) x layers.Conv2D(64, 3, paddingsame, activationrelu)(x) x layers.UpSampling2D(size(2, 2), interpolationbilinear)(x) # 128x128 x layers.Concatenate()([x, skips[block_2_add]]) x layers.Conv2D(32, 3, paddingsame, activationrelu)(x) x layers.UpSampling2D(size(2, 2), interpolationbilinear)(x) # 256x256 out layers.Conv2D(num_classes, 1, activationsigmoid)(x) model tf.keras.Model(inputsbase.input, outputsout) return model model build_lightweight_unet() model.summary()这里 num_classes1 配 sigmoid是二分类写法如果写成 num_classes2输出层就要换成 softmaxLoss 也要跟着换。UpSampling2D 用 bilinear 而不是反卷积参数少且不容易产生棋盘伪影代价是边缘略模糊对细长焊缝的影响在可接受范围。拼接前要注意特征图尺寸block_13_expand_relu 是 32x32和上采样后的 x 尺寸相同才能 Concatenate如果某个 block 尺寸对不上优先检查是不是 MobileNetV2 版本差异导致层名偏移。3.3 Loss设计Dice Loss与交叉熵的混合方案二分类语义分割最容易翻车的点是焊缝像素占比极低经常不到 5%纯交叉熵会被大面积背景带偏网络躺着输出全背景也损失不大。Dice Loss 按区域重叠计算对前景占比不敏感。实际项目里 Dice Loss 和交叉熵按 7:3 混合比单独用任何一种都稳。def dice_loss(y_true, y_pred, smooth1e-6): y_true tf.cast(y_true, tf.float32) y_pred tf.squeeze(y_pred, axis-1) y_true tf.squeeze(y_true, axis-1) intersection tf.reduce_sum(y_true * y_pred) return 1 - (2.0 * intersection smooth) / ( tf.reduce_sum(y_true) tf.reduce_sum(y_pred) smooth ) def combined_loss(y_true, y_pred): bce tf.keras.losses.binary_crossentropy(y_true, y_pred) dice dice_loss(y_true, y_pred) return 0.7 * dice 0.3 * bcesmooth1e-6 防止除以 0。如果某个 batch 里 mask 恰好全黑Dice 部分会退化成接近 1但交叉熵部分会把梯度拉回来这就是混合 Loss 的好处。类别权重如果要叠加先统计 mask 里前景像素占比 pweight(1-p)/p 再乘 0.5 缩放系数避免权重太大导致训练震荡。实际上多数场景加了 7:3 混合 Loss 之后类别权重就不是必需品了反而少一个超参数少一个坑。4. 把网络训到能出活训练参数、评估指标与曲线判读4.1 训练参数怎么定学习率、早停与模型保存语义分割在预训练编码器上的学习率不能照搬分类任务。Adam 默认 1e-3 偏大尤其编码器带预训练权重时我一般 base 层用 1e-4解码器可以放开到 3e-4。分段衰减比固定学习率省心前几十个 epoch 跑出大体形状loss 平了之后衰减到 1e-5 精修边缘。epochs 不要死定数字EarlyStopping 配合 ModelCheckpoint 保存最优权重才是常规操作。model.compile(optimizertf.keras.optimizers.Adam(learning_rate1e-4), losscombined_loss, metrics[iou_metric]) callbacks [ tf.keras.callbacks.EarlyStopping( monitorval_loss, patience20, restore_best_weightsTrue ), tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience8, min_lr1e-6 ), tf.keras.callbacks.ModelCheckpoint( weld_best.h5, monitorval_loss, save_best_onlyTrue ) ] history model.fit(ds_train, validation_datads_val, epochs120, callbackscallbacks)EarlyStopping 的 patience20 表示 20 个 epoch 没改善就停ReduceLROnPlateau 在 loss 横盘时把学习率减半。ModelCheckpoint 只保存 val_loss 最优的权重避免最后几轮过拟合反而覆盖好结果。验证集怎么留也容易被忽略焊缝数据量小按文件随机拆 train/val 不靠谱同一工件多帧图被拆到两边验证结果虚高。稳妥做法是给每张图关联工件编号按编号分组一个工件的所有帧只进一边。4.2 评估指标IoU比准确率诚实得多焊接场景里背景占比 95% 以上准确率可以躺赢到 95%但预测 mask 可能是全黑。IoU 只关心焊缝这个类的重合程度才是分割任务的核心指标。评估时把 IoU、Dice、Precision、Recall 一起报比单说准确率 98%有说服力得多。def iou_metric(y_true, y_pred, threshold0.5): y_true tf.cast(y_true, tf.float32) y_pred tf.squeeze(y_pred, axis-1) y_true tf.squeeze(y_true, axis-1) y_pred tf.cast(y_pred threshold, tf.float32) intersection tf.reduce_sum(y_true * y_pred) union tf.reduce_sum(y_true) tf.reduce_sum(y_pred) - intersection return intersection / (union 1e-6)threshold0.5 是默认分割阈值如果应用侧更怕漏检可以降到 0.3但会带进来更多反光噪声。阈值这件事建议放到后处理阶段再调模型训练阶段固定 0.5 评估就行。光看数字还不够要把原图、真值、预测结果并排可视化一眼就能看出模型错在哪。import matplotlib.pyplot as plt def visualize_prediction(model, sample_img, sample_mask, save_path): pred model.predict(sample_img[tf.newaxis, ...])[0, ..., 0] pred_bin pred 0.5 fig, axes plt.subplots(1, 3, figsize(12, 4)) axes[0].imshow(sample_img) axes[0].set_title(input) axes[1].imshow(sample_mask[..., 0], cmapgray) axes[1].set_title(ground truth) axes[2].imshow(pred_bin, cmapgray) axes[2].set_title(prediction) plt.savefig(save_path, dpi150)常见错误模式是预测 mask 比真值粗一圈通常是解码器上采样太粗糙或 Loss 对边缘约束不够如果预测结果断成几段多半是浅层跳跃连接被砍掉或者输入分辨率偏低。可视化图不要只在验证集里挑要专门挑反光、弧光、飞溅明显的样本看那才是模型真实的短板。4.3 从训练曲线判断问题五种常见形态的判读曲线形态比任何单次指标都提前暴露问题。训练 loss 降、验证 loss 不降是过拟合的典型信号。先看数据增强够不够焊缝场景至少要有亮度扰动增强已经够了就把解码器通道数减半模型容量降下来。训练 loss 和验证 loss 都高且降不动先检查学习率1e-4 再往下降到 1e-5 试几个 epoch还不行就是模型容量不够或者编码器被冻结得太死。MobileNetV2 的前几个 block 可以冻结block_13 之后必须解冻焊缝反光和飞溅纹理是 ImageNet 预训练权重里没见过的。验证 loss 剧烈震荡先怀疑 batch size 太小。256x256 输入下 batch 至少 8显存不够就降分辨率到 224不要降到 batch 2。loss 降到很低但预测全黑这是纯交叉熵被背景主导的典型症状换 mixed loss或者确认 sigmoid 输出和 mask 的类别定义没写反。验证 loss 远低于训练 loss先怀疑数据泄漏特别是按图片随机划分时同一工件的不同帧可能同时出现在两边。按工件分组重新划分这个坑在第 5 章还会细说。5. 焊缝识别避坑指南反光、失衡与训练预测不一致5.1 反光把焊缝亮出来了过分割问题现象是分割结果在焊缝两侧出现大片白色光斑single 指标 IoU 还不算差但 mask 形状已经完全不能用。原因在于反光区域灰度高、边缘模糊局部纹理和焊缝太接近网络学到的不是焊缝这个概念而是亮这个特征。解决分两条路。数据层面在增强里加入 HSV 饱和度扰动让网络多见到低饱和度的反光样本逼它去学焊缝的纹理而不是亮度。后处理层面用 OpenCV 从饱和度通道提取低饱和区域把它和预测 mask 求交集直接滤掉反光。具体做法是转 HSV 后对饱和度通道做中值滤波饱和度低于 30 的像素标记为疑似反光区再从分割结果中挖掉。5.2 焊缝占比太低loss在降mask却全黑现象是训练十几个 epoch 后 loss 平稳下降验证集预测结果整张图没有一个白点准确率还高得离谱。原因是焊缝像素占比可能不到 3%交叉熵即使全部预测为背景损失也不高网络选择了最省力的输出。解决方法是把 Loss 换成 7:3 的 Dice 加交叉熵混合Dice Loss 对前景占比不敏感。如果已经训到 loss 很低才发现不要继续死磕把学习率临时调大 2 到 3 倍重新起步让网络前期先找到前景区域再恢复小学习率精修。还有一种玄学经验是训练初期多看几个 batch 的预测输出确认 mask 里开始出现白色区域了再坐下等训练结束。5.3 训练与推理归一化不一致现象是训练集上 mIoU 能到 0.8换个文件夹的现场图预测结果全是灰色噪声。原因很简单训练时图像做了除以 255 的归一化推理时直接喂了 0 到 255 的原始值网络里 BN 层的统计量完全对不上。解决方法是把归一化写进模型而不是放在数据流水线里。在模型输入后面加一个 Lambda 层做除以 255训练和推理就走同一条路径。这个改动之后的实际效果是凡是忘了归一化的人部署阶段必翻车一次我见过不止一个项目卡在这个看起来微不足道的环节。5.4 验证集开卷考试数据划分泄漏现象是验证集 IoU 高达 0.9一到现场就掉到 0.5 以下。原因基本是同一个工件的不同角度图片被随机拆进了训练集和验证集焊缝纹理高度相似网络记住了工件本身而不是抽象的焊缝。解决方法是按工件编号分组划分数据集先分组再随机分配 train/val。采集阶段顺手把工件编号写进文件名例如 weld_003_01.jpg 表示 003 号工件的第 01 帧划分时按前缀分组。这一步改进通常能让验证集 IoU 掉下来 0.1 到 0.2但那个数值才是真实水平总比答辩现场被问住强。5.5 mask断成碎块细长结构的连续性现象是长焊缝在预测结果里断成几段或者出现孤立噪点中心线提取出来一跳一跳的。原因是解码器连续上采样放大了 16 到 32 倍小尺度特征逐级丢失细长结构不连续。解决分模型和后处理两层。模型层面检查跳跃连接是不是被砍得太狠如果为了省显存把 block_2_add 那条浅层连接去掉了加回来通常能改善断口。后处理层面用形态学闭运算连接断口核大小要和焊缝宽度匹配256x256 输入下焊缝宽度 10 到 20 像素时用 5x5 起步。断口太多的话先闭运算再提取中心线比直接做骨架化稳定得多。6. 从mask到焊缝中心线OpenCV后处理与轻量化验证6.1 用形态学与逐行质心提取焊缝中心线预测出的 mask 是像素集合实际应用里要的是中心线。我一般不用骨架化而是逐行求质心。焊缝在画面里一般是接近纵向或横向的条带逐行统计该行所有前景像素的均值坐标天然就是中心线就算 mask 中间断了一小段质心结果也基本连续抗断口能力比骨架化好。def weld_centerline(mask): mask (mask 0.5).astype(np.uint8) # 闭运算连接断口kernel大小与焊缝宽度匹配 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) ys, xs np.nonzero(mask) if len(xs) 0: return [] rows np.unique(ys) center_points [] for row in rows: cols xs[ys row] center_points.append((int(cols.mean()), row)) return center_points闭运算的 kernel 大小要根据输入分辨率调不是固定 5x5 就行。拿到中心点后可以 polyfit 拟合成直线或曲线也可以直接输出点集交给机器人控制。这一步是 OpenCV 最擅长的活没必要交给模型去做。6.2 轻量化验证先测端到端耗时再谈帧率如果目标是产线实时检测把 h5 转成 TFLite 量化后推理会快不少。但验证时不要只报模型推理帧率要测端到端读图、预处理、推理、后处理全流程耗时。import time start time.perf_counter() pred model.predict(roi_input) end time.perf_counter() print(fsingle frame: {(end - start) * 1000:.1f} ms)输入必须做和训练一致的 resize 和归一化量化模型对归一化方式更敏感差一点输出就全是噪声。这套方案做到这个程度毕设的深度和工程完整度都够用了剩下的就是反复迭代数据。我吃过一次亏实验室光照下模型表现很好拿到现场实际拍摄的照片直接崩了因为现场有弧光残留和环境杂光。所以坚持在部署前拿现场真实照片跑一遍完整流程再下结论。希望帮到你。本文还有配套的精品资源点击获取