简介:面向机器学习与工业视觉方向学习者,提供一套完整的焊缝自动识别项目资料,基于TensorFlow构建深度学习模型,并结合OpenCV完成图像预处理、特征提取与数据增强,适合想掌握从数据集构建、模型训练到评估部署全流程的毕设或工程实践人员。压缩包共27个文件,大小7.52MB,涵盖CNN模型代码、训练得到的pb模型与检查点数据、标注用XML文件、图像样本及项目说明等,便于对照学读与实践复现。目前已有219人学习下载,资源整体结构清晰,核心脚本与模型文件齐备,能帮助读者快速理解焊缝识别任务的实现思路,也适合作为图像分类、模型导出与部署方面的参考案例。
1. 焊缝识别不是滤镜:为什么这个毕设方向值得动手
焊缝识别在工业视觉里一直是块硬骨头。传统阈值分割在干净背景下勉强能看,一旦遇到弧光、飞溅、打磨痕迹,分割结果就稀碎。把这个问题交给 TensorFlow+OpenCV,本质上是换了一条路: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_ok=True) # 根据产线相机安装位置固定的先验,裁剪出焊缝出现的区域 # 参数含义: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), interpolation=cv2.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_id=1): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) mask = np.zeros(img_shape, dtype=np.uint8) for shape in data["shapes"]: if shape["label"] != "weld": # 只处理焊缝类别,其他标注忽略 continue pts = np.array(shape["points"], dtype=np.int32) cv2.fillPoly(mask, [pts], color=class_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_delta=0.1) # 对比度扰动:反光场景下对比度变化剧烈 image = tf.image.random_contrast(image, lower=0.8, upper=1.2) return image, mask def load_sample(img_path, mask_path): img = tf.io.read_file(img_path) img = tf.image.decode_jpeg(img, channels=3) img = tf.image.resize(img, (256, 256)) mask = tf.io.read_file(mask_path) mask = tf.image.decode_png(mask, channels=1) # mask用最近邻重采样,避免插值产生中间灰度值 mask = tf.image.resize(mask, (256, 256), method=tf.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_calls=tf.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_classes=1): # 编码器用ImageNet预训练权重,重点是浅层特征不动,高层微调 base = tf.keras.applications.MobileNetV2( input_shape=input_shape, include_top=False, weights="imagenet" ) # 取三个尺度的特征,后面做跳跃连接: # 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, padding="same", activation="relu")(x) x = layers.UpSampling2D(size=(2, 2), interpolation="bilinear")(x) # 32x32 x = layers.Concatenate()([x, skips["block_13_expand_relu"]]) x = layers.Conv2D(128, 3, padding="same", activation="relu")(x) x = layers.UpSampling2D(size=(2, 2), interpolation="bilinear")(x) # 64x64 x = layers.Concatenate()([x, skips["block_5_add"]]) x = layers.Conv2D(64, 3, padding="same", activation="relu")(x) x = layers.UpSampling2D(size=(2, 2), interpolation="bilinear")(x) # 128x128 x = layers.Concatenate()([x, skips["block_2_add"]]) x = layers.Conv2D(32, 3, padding="same", activation="relu")(x) x = layers.UpSampling2D(size=(2, 2), interpolation="bilinear")(x) # 256x256 out = layers.Conv2D(num_classes, 1, activation="sigmoid")(x) model = tf.keras.Model(inputs=base.input, outputs=out) return model model = build_lightweight_unet() model.summary()这里 num_classes=1 配 sigmoid,是二分类写法;如果写成 num_classes=2,输出层就要换成 softmax,Loss 也要跟着换。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, smooth=1e-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 * bcesmooth=1e-6 防止除以 0。如果某个 batch 里 mask 恰好全黑,Dice 部分会退化成接近 1,但交叉熵部分会把梯度拉回来,这就是混合 Loss 的好处。类别权重如果要叠加,先统计 mask 里前景像素占比 p,weight=(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(optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss=combined_loss, metrics=[iou_metric]) callbacks = [ tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=20, restore_best_weights=True ), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=8, min_lr=1e-6 ), tf.keras.callbacks.ModelCheckpoint( "weld_best.h5", monitor="val_loss", save_best_only=True ) ] history = model.fit(ds_train, validation_data=ds_val, epochs=120, callbacks=callbacks)EarlyStopping 的 patience=20 表示 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, threshold=0.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)threshold=0.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], cmap="gray") axes[1].set_title("ground truth") axes[2].imshow(pred_bin, cmap="gray") axes[2].set_title("prediction") plt.savefig(save_path, dpi=150)常见错误模式是预测 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(f"single frame: {(end - start) * 1000:.1f} ms")输入必须做和训练一致的 resize 和归一化,量化模型对归一化方式更敏感,差一点输出就全是噪声。这套方案做到这个程度,毕设的深度和工程完整度都够用了,剩下的就是反复迭代数据。我吃过一次亏:实验室光照下模型表现很好,拿到现场实际拍摄的照片直接崩了,因为现场有弧光残留和环境杂光。所以坚持在部署前拿现场真实照片跑一遍完整流程,再下结论。希望帮到你。
本文还有配套的精品资源,点击获取