光伏电池片缺陷识别实战:Python与深度学习在工业视觉中的落地路径
简介面向光伏产线质检与计算机视觉学习者这是一套基于Python实现的光伏电池片图像缺陷自动识别检测项目覆盖从原始照片到缺陷判定的完整链路。针对倾斜拍摄的电池板组件代码先通过直方图自适应二值化与透视变换进行图像校正再提取行列特征并利用FFT频谱分析确定晶片行列排布进而分割出每片电池片可分别采用非线性SVM与DenseNet对分割后的照片训练实现缺陷自动识别。压缩包共30个文件约20.75MB其中16个Python脚本按功能拆分另有pb/index模型权重、xls标注数据、config.json及requires.yaml配置SVM与DenseNet各自包含训练、测试与演示代码便于对比两种方案的差异。目前已有201人学习浏览适合算法学习者、光伏检测工程师参考整个工程化流程并根据详细说明快速复现和扩展。1. 光伏电池片缺陷识别为什么这件事值得你亲手做一遍光伏电池片在切割、扩散、印刷、烧结的每一道工序里都可能留下隐裂、断栅、色差、脏污这类表面缺陷。这些缺陷肉眼能看出来但产线节拍是按秒算的人工质检盯久了必然漏检。把图像缺陷自动识别检测做成一个 Python 项目本质上是训练一个图像分类或目标检测模型让它代替人眼完成“有缺陷 / 无缺陷”甚至“缺陷类型定位”的判断。这个方向最大的价值不是算法有多新而是它直接对接产线良率一套能跑通的源码加模型就能让你在本地复现从图像预处理、模型训练到推理输出的完整链路。这篇文章我按自己做过的一条完整技术路线来讲数据集怎么准备、模型怎么选、训练参数怎么调、推理代码怎么写、哪些坑会让你白训一晚上。适合刚接触工业视觉的 Python 工程师也适合已经有了模型但被准确率卡住的人照着排查。我们不追求炫技只求每一步都能落地。2. 先搞懂任务边界分类、检测还是分割决定了你这套代码的骨架2.1 三类任务的差异别一上来就上 YOLO光伏电池片缺陷识别的常见做法有三条路。第一是图像分类整张图判断“有没有缺陷”最常见的是 EL电致发光图像里判断隐裂和断栅输入一张整片图输出正常或异常第二是目标检测用矩形框把缺陷的位置和类别标出来适合碎片率、崩边这类有明确位置的缺陷第三是语义分割逐像素判断每个点是否异常适合裂纹这种形状不规则、边界模糊的缺陷。我见过很多新手直接把 EL 图像塞进 YOLO 训练结果 mAP 一直上不去。原因很简单EL 图里的隐裂是一条细线标注框稍微大一点背景占比就超过 90%模型学到的全是“有框的地方大概率是背景”这种假规律。所以在动手之前你要先想清楚产线到底要什么结果。如果只关心“这片能不能用”分类模型就够了如果要定位缺陷去反查工序再考虑检测或分割。2.2 源码里最常见的工程结构按模块拆别把一切堆在一个文件里拿手头这类“源代码模型”的项目来说通常你会看到一个包含 data、models、utils、checkpoints 几个目录的工程。data 目录放原始图像和标注文件models 目录放网络结构定义utils 放预处理和可视化工具checkpoints 放训练好的权重。这种结构不是摆样子是让你能在不动业务逻辑的前提下换模型、换数据集。我一般会在动手前先跑通一个最小推理脚本确认权重、预处理和类别映射是对的。下面是最小推理的骨架用 PyTorch 实现import torch from torchvision import transforms from PIL import Image from models.defect_cnn import DefectCNN # 类别顺序必须和训练时保持一致 CLASS_NAMES [normal, crack, finger, stain] def preprocess(image_path, size(224, 224)): # EL图像通常是单通道灰度图但为了能用ImageNet预训练转成三通道RGB复制 img Image.open(image_path).convert(RGB) transform transforms.Compose([ transforms.Resize(size), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) return transform(img).unsqueeze(0) def predict(model_path, image_path): model DefectCNN(num_classeslen(CLASS_NAMES)) # 严格按训练时的key加载不要用weights_onlyFalse之外的参数随意改 model.load_state_dict(torch.load(model_path, map_locationcpu)) model.eval() with torch.no_grad(): output model(preprocess(image_path)) prob torch.softmax(output, dim1) idx torch.argmax(prob, dim1).item() return CLASS_NAMES[idx], prob[0][idx].item() if __name__ __main__: name, confidence predict(./checkpoints/best.pt, ./data/sample/crack.jpg) print(f预测类别: {name}, 置信度: {confidence:.4f})这里有两个关键点。第一CLASS_NAMES的顺序必须和训练时dataset里class_to_idx的映射一致否则会出现“模型输出正常但标签对不上”的幻觉问题。第二EL 图如果是单通道convert(RGB)会把灰度复制成三份这样就能直接使用 ImageNet 预训练权重而不是从零开始训练收敛速度会明显改善。3. 准备数据集从原始图像到可训练样本的完整链路3.1 原始图像的分割整片图不是训练的最小单元光伏电池片相机拍出来的原图往往是 1024×1024 甚至更大直接塞进 CNN 会让显存爆炸而且缺陷在整图里占比太小模型很难学到特征。常见做法是把原图切成固定大小的 patch图块常见的切分尺寸有 256×256、224×224 和 512×512。切得太小一条横贯整片电池片的隐裂会被切成几十段每一段看起来只是“一条短线”模型容易把它跟划痕混淆切得太大正常区域和缺陷区域的比例依然悬殊训练效率低。我常用的策略是缺陷区域用滑动窗口切正常区域用随机采样切。做缺陷检测时正常样本数量往往远多于缺陷样本如果直接整图切训练集里正常 patch 占到 95% 以上模型会学成“永远输出正常”。对缺陷 patch 要做数据增强对正常 patch 反而要克制增强否则模型会去记住增强后的噪声而不是真实表面纹理。3.2 标注格式分类和检测的标注千万别混着用如果你的方案是分类模型每个 patch 只需要一个文件夹名作为标签结构类似data/train/ normal/ 0001.png 0002.png ... crack/ 0001.png 0002.png ... finger/ 0001.png 0002.png ...如果你的方案是目标检测则需要 VOC 格式的 XML 或 YOLO 格式的 TXT 标注。YOLO 格式的每行是“类别 中心x 中心y 宽度 高度”坐标全部归一化到 01。这里最容易翻车的是坐标原点不一致有的标注工具原点在左上角有的在左下角转换脚本写错一个符号训练出来的框就全偏了。我习惯在进入训练前绘制一遍标注框检查图把框叠加到原图上看一眼这一步能救回半天的排查时间。3.3 做一份可复用的训练集划分脚本下面这段脚本按 8:1:1 划分数据集并按缺陷类型做分层采样保证每一折里都有足够的正样本import os import random import shutil from collections import defaultdict def split_dataset(source_root, target_root, ratios(0.8, 0.1, 0.1), seed42): random.seed(seed) for split in [train, val, test]: os.makedirs(f{target_root}/{split}, exist_okTrue) # 按类别分组避免某一类只在训练集、测试集一个都没有 for cls in os.listdir(source_root): cls_dir os.path.join(source_root, cls) if not os.path.isdir(cls_dir): continue files [f for f in os.listdir(cls_dir) if f.endswith(.png)] random.shuffle(files) n_train int(len(files) * ratios[0]) n_val int(len(files) * ratios[1]) groups { train: files[:n_train], val: files[n_train:n_train n_val], test: files[n_train n_val:] } for split, flist in groups.items(): dest f{target_root}/{split}/{cls} os.makedirs(dest, exist_okTrue) for f in flist: shutil.copy(os.path.join(cls_dir, f), os.path.join(dest, f)) if __name__ __main__: split_dataset(./data/raw, ./data/split)脚本里的defaultdict部分在实际写的时候可以换成更简单的方式核心思路是“按类别 shuffle 后再切”而不是全局 shuffle。全局 shuffle 的缺点是缺陷类别样本少很可能某一类在测试集里只有三五张导致测试指标方差极大。你要是在源码里看到只对文件名做整体随机划分的代码建议改成这种分层划分。4. 模型选型与训练参数从 ResNet 到轻量分类网络的实际调参经验4.1 为什么我推荐先试 ResNet 而不是 Vision Transformer光伏电池片表面缺陷的纹理特征和自然图像差异很大Imagenet 预训练模型提取到的边缘、纹理特征依然有效但深层语义特征并不完全迁移。Vision Transformer 需要大量数据才能发挥优势产线实际能拿到的缺陷样本往往只有几千张硬上 ViT 会过拟合。ResNet 系列里ResNet18 和 ResNet50 的性价比最高ResNet18 足够快适合跑通流程ResNet50 精度更高适合做最终交付。如果项目源码里自带模型文件通常能看到一个defect_cnn.py或者models/resnet.py。常见的做法不是从零实现 ResNet而是用torchvision.models.resnet50加上修改最后一层全连接输出维度。要注意预训练权重的下载地址可能因为网络环境不同而失败遇到这种情况就手动下载.pth文件放到~/.cache/torch/hub/checkpoints/目录。4.2 训练脚本中 5 个必调的参数数据、模型、优化器这三件套确定后真正影响最终准确率的就是下面这 5 个参数。第一个是学习率。用预训练权重微调时主干网络的学习率通常设为1e-4新增的全连接层可以设成1e-3。很多源码会混用model.fc.parameters()和model.parameters()分别设置不同学习率这个设计是合理的但新手直接全局用1e-4也能跑只是收敛慢一点。第二个是 batch size。EL 图像经过切片后通常远小于自然图像显存压力不大。分类任务 8GB 显存跑 ResNet50 用 batch size 32 没问题。注意如果 batch size 从 32 改成 64最好同步把学习率从1e-4调大到2e-4线性缩放规则是实践经验里最直接有效的一招。第三个是类别权重。缺陷样本少时在损失函数里给缺陷类别更高的权重能直接缓解正负样本不均衡。PyTorch 的交叉熵损失可以这样传入weightimport torch.nn as nn weights torch.tensor([1.0, 3.0, 5.0, 4.0]) # normal, crack, finger, stain criterion nn.CrossEntropyLoss(weightweights.to(device))这个参数需要基于训练集的实际分布来定。比如 normal 有 10000 张crack 有 3000 张类别权重的经验值是总样本数 / (类别数 * 该类样本数)而不是拍脑袋填。第四个是数据增强策略。我推荐的增强组合是随机水平翻转、随机垂直翻转、随机旋转 15 度、随机亮度对比度调整。不要用 RandomResizedCrop因为电池片缺陷的位置和尺度有比较强的先验过度裁剪会让模型学到不真实的局部。增强强度在工业场景下要保守翻转和微小的颜色抖动就够了。第五个是早停和模型保存。训练时最好的做法是每一个 epoch 结束都记录验证集准确率只有验证准确率创新高时才覆盖保存best.pt。如果你只保存最后一次 epoch 的权重那大概率会得到一份在验证集上已经过拟合的模型。4.3 从训练日志判断模型状态训练时输出 loss 曲线是基本操作。我习惯同时记录 train_loss、val_loss 和 val_acc。如果 train_loss 一直下降但 val_loss 在第 10 个 epoch 开始反弹就是典型过拟合信号这时候应该回退到第 9 个 epoch 的权重而不是继续训练。如果 train_loss 和 val_loss 都降不下去先检查数据预处理是不是出了问题比如归一化用了错误的均值和标准差或者图片通道顺序 BGR 和 RGB 弄反了。5. 模型推理部署把训练好的模型变成产线能用的接口5.1 导出为 TorchScript 或 ONNX 的时机训练完成的.pt文件只能在 PyTorch 环境里跑。如果产线需要 C 推理或者部署在无 PyTorch 环境的主机上需要导出为 TorchScript 或 ONNX。TorchScript 的导出最简单但 ONNX 的生态兼容性更好。导出 ONNX 的常见步骤是import torch from models.defect_cnn import DefectCNN model DefectCNN(num_classes4) model.load_state_dict(torch.load(./checkpoints/best.pt, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, ./checkpoints/best.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version11 )导出 ONNX 时最容易踩的坑是模型里有动态尺寸的 op。有些模型在 forward 里用了nn.Flatten()但 shape 推断依赖于输入尺寸如果训练时固定 224×224导出时 dummy_input 也必须是 224×224否则会报错或者导出的模型在别的尺寸下输出错误。建议导出后先用 ONNX Runtime 跑一遍和 PyTorch 相同的输入对比两者输出的最大误差误差在1e-5量级是正常的。5.2 推理代码的批处理优化单张图推理只求效果时直接调predict就行。但产线视觉系统往往一次来一批 16 张或者 32 张图逐张推理会有大量 Python 层开销。把预处理和推理写成 batch 形式能提速三到五倍import torch from torchvision import transforms from PIL import Image def batch_predict(model, image_paths): transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) batch torch.stack([transform(Image.open(p).convert(RGB)) for p in image_paths]) with torch.no_grad(): logits model(batch) probs torch.softmax(logits, dim1) return probs # 用法传入一批路径得到形状为 [N, 4] 的概率矩阵这里每个图像的预处理还是 Python 循环但如果未来瓶颈在PIL.Image.open可以考虑先统一批量读取图片再组合 batch。这个优化在 CPU 推理时效果尤其明显因为 CPU 推理的瓶颈不是矩阵计算而是内存拷贝和 Python 调度。5.3 类别不均衡带来的阈值调整模型输出的置信度并不是直接拿来当生产判断标准的。因为缺陷样本少模型即使学到正确特征对缺陷类别的置信度也普遍偏低。生产环境的做法是设置两道阈值当正常类别置信度大于等于 0.9 判定为正常当缺陷类别置信度在 0.5 到 0.9 之间判定为“疑似缺陷”弹出来让人工复检。这道阈值不用在训练阶段调而是在测试集上绘制 PR 曲线之后根据漏检率来确定。我见过一个实际案例把正常类别的判断阈值从 0.5 提高到 0.85 之后漏检率从 12% 降到了 4%代价只是把 8% 的正常片判成了“疑似缺陷”需要多看一遍。这就是精度和召回率的取舍没有标准答案要根据产线对漏检和误判的容忍度来定。6. 缺陷检测避坑清单5 个让我通宵排查过的真实问题6.1 训练时 loss 掉不下去准确率一直在百分之四十几现象训练经过十几个 epochloss 稳定在 1.38 左右验证准确率始终在 40% 附近相当于随机猜测。原因类别顺序映射错误。数据加载器返回的标签是按文件夹名排序的os.listdir返回的顺序不一定和CLASS_NAMES一致比如文件夹叫normal但排序后class_to_idx把它映射成了 1而网络输出索引 0 对应normal。解决在训练脚本里打印dataset.class_to_idx再和CLASS_NAMES手动核对一遍。正确的做法是显式定义类别到索引的字典而不是依赖文件夹遍历顺序。6.2 验证集准确率很高新拍的图片却全判成正常现象训练时验证集准确率 98%客户拿新产线的电池片一测正常片全输出正常缺陷片也输出正常。原因训练集和现场图片的数据分布不一致常见的是现场图像亮度和 EL 图像亮度不一致模型学到的是“偏亮的图是正常”而不是真实的纹理特征。解决对现场采样的图片做亮度归一化比如把灰度图均值缩放到接近训练集的均值。更彻底的做法是在数据增强里加入更大幅度的亮度扰动让模型学到亮度不变性。这个问题的根源是数据采集不是模型参数。6.3 显存不足 OOM但换小 batch size 又会导致准确率下降现象8GB 显卡跑 ResNet50batch size 设 32 直接 OOM改成 4 能跑但准确率掉了几个点。原因batch size 越小batch normalization 统计量越不稳定尤其是数据集本身缺陷样本少小 batch 里可能全是正常样本。解决保留大 batch size 的前提下开启梯度累积每 4 个 step 更新一次参数等效 batch size 是 16显存占用不变。这条在源码里一般不会写需要自己加。6.4 加载别人训练好的模型报 missing keys 和 unexpected keys现象torch.load后model.load_state_dict报一堆 “Missing key(s) in state_dict” 和 “Unexpected key(s)”。原因对方训练的模型类别数和你不同或者是分类头之前的特征层命名不同比如对方用的模块叫head你的模块叫fc。解决打印 state_dict 的 key 列表和模型 key 列表做一次键名映射。更省事的做法是官方预训练权重先加载再用你自己的数据微调而不是直接加载一个自定义征头的未知权重。6.5 推理时图像尺寸不一致导致结果异常现象同一张缺陷图resize 到 224 识别正确resize 到 512 反而识别成正常。原因模型训练时的输入尺度和推理时的输入尺度不同。工业图像里裂纹和断栅是高频细节原图下采样到 224 时细线可能刚好被保留下来但直接 resize 到 512 再下采样插值方式不同会带来不同的混叠效果。解决严格统一训练和推理时的 resize 尺寸、插值算法。推荐在推理代码里固定transforms.Resize((224, 224), interpolationImage.BILINEAR)不要偷懒用 OpenCV 的默认插值。7. 进阶技巧用类激活图给产线一个“为什么判缺陷”的解释模型判出缺陷但现场工程想知道模型到底看了图像的哪个区域。这一步通常用 Grad-CAM 来做对一片误判的图像绘制热力图能看到模型关注的是裂纹附近还是电极栅线附近。下面是一个简单的 Grad-CAM 实现思路import torch import cv2 import numpy as np from torchvision import transforms from PIL import Image def grad_cam(model, image_path, target_layer, class_idx): model.eval() img Image.open(image_path).convert(RGB) transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) input_tensor transform(img).unsqueeze(0).requires_grad_(True) features None gradients None def forward_hook(module, input, output): nonlocal features features output def backward_hook(module, grad_input, grad_output): nonlocal gradients gradients grad_output[0] handle_f target_layer.register_forward_hook(forward_hook) handle_b target_layer.register_full_backward_hook(backward_hook) output model(input_tensor) score output[0, class_idx] model.zero_grad() score.backward() weights torch.mean(gradients, dim(2, 3), keepdimTrue) cam torch.relu((weights * features).sum(dim1, keepdimTrue)) cam cam.squeeze().cpu().detach().numpy() cam cv2.resize(cam, (224, 224)) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) handle_f.remove() handle_b.remove() return cam这段代码在业务上的用法是当模型把某片正常电池片判成缺陷时绘制 Grad-CAM 图如果模型关注的区域是电极栅线说明模型学到了错误特征你需要回看训练集里正常样本和缺陷样本是否混入了大量栅线区域。如果热力图准确地覆盖了裂纹所在位置那模型的判断有依据可以考虑调低缺陷阈值而不是修改模型。我自己的习惯是在新数据集上训练完成后第一件事不是看测试集准确率而是随机抽 20 张验证集图像对每张误判图像画出 Grad-CAM 热力图。这一步能让我在五分钟内判断模型是不是学歪了。最近一次做电池片隐裂检测模型把两条栅线之间的暗区当成缺陷热力图告诉我它关注了不该关注的周期性纹理后来在训练集里增加了带栅线的正常样本问题就消失了。这也是我建议每一个做光伏缺陷识别的人养成的习惯深度学习模型不是黑匣子只要愿意把热力图拉出来看它的决策依据比你想的要透明得多。希望这篇笔记能让你少走几段弯路把你的检测模型真正推到产线上跑起来。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →