
简介基于Python实现的光伏电池片缺陷自动识别检测项目面向光伏质检从业者、计算机视觉学习者及竞赛调参人员提供从图像校正到缺陷分类的完整工程方案。系统针对倾斜电池板照片利用直方图自适应二值化与透视变换完成几何校正再结合FFT频谱分析提取晶片行列排布实现精准分割后续可切换非线性SVM或DenseNet两种模型进行缺陷训练与识别深度覆盖传统机器学习与深度学习方法。包体共30个文件约20.75MB包含16个Python源码模块、预训练模型pb与index权重文件、标签Excel表、配置JSON/YAML及README说明结构清晰便于直接复现与二次开发。已有201人学习下载适合希望快速上手工业视觉检测项目的读者。资源额外附带命令行交互操作说明、数据集划分与标签转换脚本中文注释与部署指南齐全可帮助理解整条检测流水线并迁移至其他板面检测场景。1. 光伏电池片图像缺陷自动识别检测从校正到分类的一条龙方案光伏电池片图像缺陷自动识别检测真正的难点不在最后的分类模型而在图像校正和分割。这套 Python 项目把整条链路串好了倾斜原图先做直方图自适应二值化抠出电池板再做透视变换拉正用 FFT 频谱分析定位晶片行列把每个晶片切成独立小图最后交给非线性 SVM 或 DenseNet 做缺陷分类。下载到的是从拍歪的原图到缺陷标注结果的可复现流程不是裸模型。拆包后的第一感受是交互式命令行省事python main.py 起来后show folder、set image、set model 一路敲下去就能出结果适合刚接触检测的从业者快速验证。仓库保留了 train.py、autosegment.py、label_convert.py模型不写死可以拿自家产线照片重训。适合一线光伏质检工程师、想跑通完整流程的算法新人以及需要完整可运行案例的学生。环节边界清晰是最大优点预处理、分割、训练、推理各自独立成脚本哪里出问题就查哪一段。2. 图像预处理与分割自适应二值化、透视变换和 FFT 频谱分析怎么配合2.1 直方图自适应二值化光照不均时怎么把电池板抠出来光伏板大多在室外或车间自然光下拍摄光照不均几乎是常态。板子朝光的一面亮、背光的一面暗固定阈值 127 的写法在这种场景下必翻车——亮区背景被当成目标暗区目标被当成背景。项目里用的直方图自适应二值化思路是先统计整张灰度图的直方图分布找到背景峰和目标峰之间的谷底作为基础阈值再按局部窗口做微调相当于 Otsu 全局阈值和自适应局部阈值的折中。对应到代码层面最常见的落地方式是给每个像素算一个局部基准再减去常数 Cimport cv2 import numpy as np def adaptive_binarize(img_path, block_size15, c2): gray cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if gray is None: raise FileNotFoundError(f读不到图片: {img_path}) # 先用大津法拿到全局阈值作为二值化的基准 _, global_bin cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 再用局部自适应阈值补偿光照不均block_size 必须是奇数 local_bin cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size, c ) # 两路结果取交集保住电池板大块连通域滤掉孤立噪点 binary cv2.bitwise_and(global_bin, local_bin) kernel np.ones((3, 3), np.uint8) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) return binaryblock_size 是局部邻域边长15 对应 15x15 像素窗口适合千万像素级别的大图如果原图更大我会先缩放到长边 1600 再处理否则邻域太小会把电池板内部的栅线也误判成背景。c 是阈值偏移常数c 越大越不容易吃掉暗部细节但过大又会被阴影干扰。我一般从 2 开始看到二值图里电池板边缘有锯齿就往大调看到内部栅线断了就往小调。注意adaptiveThreshold 的 block_size 必须是奇数写成 14 会直接报错这是最常见的低级错误。另外这一步的输出只服务两个目的找电池板外轮廓、给透视变换提供角点所以不用在意晶片内部细节是否完整只要板子整体是个白色大块就行。2.2 透视变换把倾斜拍摄的电池板拉回正面视图户外拍光伏板很少能正对着拍巡检车、无人机出来的图多少带透视畸变。不校正就做行列分割靠近镜头的晶片被放大、远处被压缩网格必然对不齐。项目在二值化之后先提取最大连通域外轮廓再用多边形逼近出四个角点最后用 cv2.getPerspectiveTransform 做射影变换。角点提取这一步有些玄学成分光靠轮廓逼近经常把角点顺序打乱所以必须显式做一次左上、右上、右下、左下的排序def order_corners(pts): # 输入四个角点的 (x, y) 坐标输出按左上/右上/右下/左下排序 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) # x y rect[0] pts[np.argmin(s)] # 左上和最小 rect[2] pts[np.argmax(s)] # 右下和最大 d np.diff(pts, axis1) # y - x rect[1] pts[np.argmin(d)] # 右上差最小 rect[3] pts[np.argmax(d)] # 左下差最大 return rect def four_point_transform(img, corners): tl, tr, br, bl order_corners(corners) # 目标宽度取上下两条边的较大值高度取左右两条边的较大值 w_top np.linalg.norm(tr - tl) w_bottom np.linalg.norm(br - bl) h_left np.linalg.norm(bl - tl) h_right np.linalg.norm(br - tr) w int(max(w_top, w_bottom)) h int(max(h_left, h_right)) dst np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtypefloat32) M cv2.getPerspectiveTransform(order_corners(corners), dst) return cv2.warpPerspective(img, M, (w, h))排序逻辑不复杂左上角 xy 最小右下角 xy 最大右上角 y-x 最小左下角 y-x 最大。这个排序在图片旋转超过 90 度时失效但光伏板巡检图里板子基本是横着或竖着倾斜不会倒置够用。warpPerspective 之后的长宽比是从角点的像素距离估出来的不是真实物理尺寸但后续分割只需要相对位置不需要绝对比例所以不影响。如果校正后板子边缘还有黑边我会再加 2~3 像素的裁剪否则 FFT 频谱分析时黑边会变成一个虚假的强周期。2.3 FFT 频谱分析从频域峰值推算晶片行列网格校正后的电池板图像有很强的周期结构晶片、栅线、晶片、栅线交替排列。这个周期在空间域不好直接数在频率域却非常明显。项目把校正图按行和列分别做灰度均值投影得到两条一维信号再对它们做 FFT主峰位置就对应晶片排列的周期长度。import numpy as np def estimate_period(profile): # profile 是沿某个方向的灰度均值序列 profile profile - profile.mean() # 去掉直流分量 spectrum np.fft.rfft(profile) magnitude np.abs(spectrum) freqs np.fft.rfftfreq(len(profile), d1) # 跳过 0 频分量找最大幅值对应的频率 peak_idx int(np.argmax(magnitude[1:])) 1 if freqs[peak_idx] 0: return 0 return int(round(1.0 / freqs[peak_idx])) def find_grid(corrected_gray): col_mean corrected_gray.mean(axis0) # 沿列投影反应竖向栅线 row_mean corrected_gray.mean(axis1) # 沿行投影反应横向栅线 cell_w estimate_period(col_mean) cell_h estimate_period(row_mean) return cell_w, cell_h投影信号里栅线位置灰度值低周期就体现在频谱峰值上。用 rfft 而不是 fft是因为输入是实数信号rfft 只算一半频段速度更快且峰值位置不变。d1 表示相邻像素间距为 1频率单位就是每像素周期数取倒数就是像素单位的周期长度。拿到 cell_w 和 cell_h 之后分割就是滑窗裁切。这里有个实操细节每个 cell 四周要再切掉 1~2 像素把相邻晶片之间的栅线残影去掉否则单张 cell 边缘会出现两条不属于它的亮线模型很容易把这种伪影学成特征。def segment_cells(image, cell_w, cell_h, margin2): h, w image.shape[:2] cells [] for y in range(0, h - cell_h 1, cell_h): for x in range(0, w - cell_w 1, cell_w): cell image[y margin:y cell_h - margin, x margin:x cell_w - margin] cells.append(cell) return cells3. 两套检测模型非线性 SVM 和 DenseNet 的选型与训练参数3.1 非线性 SVM样本少的时候最稳的起点光伏缺陷样本不像公开数据集那么好攒正常片一抓一大把缺陷片可能翻一整个车间才几十张。数据量在几百到几千这个量级时DenseNet 这类深度网络发挥不出来反而是带 RBF 核的非线性 SVM 更靠谱——特征维度几千、样本量几百SVM 能靠支持向量撑住决策边界不容易过拟合。项目里 KernelSVM_model.py 走的也是这条路。特征这块常见做法是 HOG它对栅线纹理敏感比直接摊平的原始像素稳定import cv2 from skimage.feature import hog def extract_hog_features(img_path, size(64, 64)): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, size, interpolationcv2.INTER_AREA) features hog( img, orientations9, pixels_per_cell(8, 8), cells_per_block(2, 2), block_normL2-Hys ) return features64x64 的图切成 8x8 cell一共 64 个 cell2x2 block 滑动得到 49 个 block每个 block 是 4 个 cell 的 9 方向直方图拼起来共 36 维特征总长 49 x 36 1764 维。这个维度对 SVM 很舒适不需要再做 PCA。训练部分直接用 scikit-learn 的 SVC核函数选 rbfC 和 gamma 是决定成败的两个参数from sklearn.svm import SVC from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler svm_model make_pipeline( StandardScaler(), SVC( kernelrbf, C1.0, gammascale, probabilityTrue, class_weightbalanced, random_state42 ) ) svm_model.fit(extract_all_hog_features(), labels)C 控制误分类惩罚C 越大边界越紧、越容易过拟合缺陷样本噪声大时我一般先给 0.5 而不是默认的 1.0。gammascale 等于 1/(特征数 * 特征方差)配合 StandardScaler 把量纲拉平这个组合在大多数图像特征上都比手调 gamma 省心。class_weightbalanced 是针对缺陷/正常比例失衡的如果正常片占 90%不设这个参数模型会倾向于把所有片都判成正常。3.2 DenseNet样本量上来之后的精度上限当分割后的样本总量超过几千张尤其缺陷类别足够多时就该上 DenseNet 了。项目里 DenseNet 分支用的是标准 densenet121 结构4 个 dense block 加 transition 层growth rate 默认 32参数量在 700 万量级单卡 GPU 就能训练。import torch import torch.nn as nn from torchvision.models import densenet121 def build_densenet(num_classes2, pretrainedTrue): weights DEFAULT if pretrained else None model densenet121(weightsweights) in_features model.classifier.in_features # 替换最后的分类器适配二分类 model.classifier nn.Linear(in_features, num_classes) return model如果只是想验证流程pretrained 直接用 ImageNet 预训练权重最快如果训练数据是自己拍的近红外或 EL 图和 ImageNet 分布差异很大预训练作用会打折扣不如随机初始化从头训。这里我通常做两阶段实验先用预训练权重训 20 个 epoch 看验证集趋势再随机初始化重训一轮对比哪个 validation loss 更低。训练配置上我一般给 Adam 加 CosineAnnealing初始学习率 1e-4权重衰减 1e-4批量大小 16 到 32。输入尺寸看分割 cell 的大小128x128 够用拉到 224x224 精度提升有限但训练时间翻倍optimizer torch.optim.Adam(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion nn.CrossEntropyLoss()数据增强我只用水平翻转、小角度旋转±10 度和亮度抖动不做随机裁剪。光伏晶片内部结构高度规则随机裁剪很容易把栅线裁断反而引入不存在的缺陷。3.3 两套模型的对比与选择对比项非线性 SVMRBFDenseNet适合样本量几百到几千几千以上训练时间分钟级CPU 即可小时级建议 GPU光照变化鲁棒性依赖 HOG 归一化增强做过后相对稳输出解释性决策边界直观黑匣子缺陷类型扩展需重新提特征加类别重训即可单张推理速度上 SVM 在毫秒级DenseNet 在 CPU 上约几十毫秒都满足产线离线抽检。真正的选择标准就一条样本少、机器没 GPU用 SVM样本多、精度有硬指标用 DenseNet。项目把两个模型都留着正好做 A/B 对比。但缺陷检测里不要只看准确率漏检的代价远高于误检后续评估要重点盯召回率。4. 交互式命令行复现从放图到出结果的完整操作4.1 目录与依赖准备拿到代码包解压后根目录结构基本是main.py 入口、image_utils.py 图像处理工具、KernelSVM_model.py 与 DenseNet 两个分支各自带 train.py、test.py、saved_model、label_convert.py、automove.py、autosegment.py、config.json、requires.yaml以及两个 xls 标签文件。这份源代码约定了一个固定工作目录第一步先在根目录建 photos 文件夹cd SolarPanelDefectDetect-master mkdir photos环境依赖方面requires.yaml 是 YAML 格式的环境描述文件不是 pip 能直接读的 requirements.txt。常见做法是手动装齐主要依赖pip install opencv-python numpy scikit-learn scikit-image pip install torch torchvision pip install openpyxl pandasDenseNet 用纯 CPU 也能跑只是训练慢。装完后自检一下python -c import cv2, torch, sklearn 不报错就可以了。4.2 main.py 交互指令逐条拆解主程序是交互式的起来之后一行一行敲指令python main.py一次完整的检测会话长这样 show folder 0: photos set folder 0 show image 0: panel_001.jpg 1: panel_002.jpg set image panel_001.jpg show model 0: SVM 1: DenseNet set model SVM detect output: ./output/panel_001_segmentedshow folder 列出根目录下可用的目标文件夹photos 是约定名字set folder 后面跟名字或编号都行。show image 列出当前文件夹下所有 .jpg/.JPG 图片set image 选具体图片同样支持编号。show model 列出已经保存好的两个模型分别对应两张 saved_model 目录。detect 触发完整处理链读图、自适应二值化、透视变换、FFT 定位行列、分割、模型分类、输出标注。输出目录下除了分割出的各晶片小图缺陷片还会被单独标出方便人工复核。4.3 用自己的数据重训模型重训分两步先用 label_convert.py 把目录结构转成 LabelList.xls再跑 train.py。假如你的分割图按 normal/defect 两个子文件夹放着转换脚本会按文件名排序后生成文件名与类别两张表python label_convert.py --data ./segmented --out LabelList.xls转换脚本的核心逻辑是按文件名做字典序排序然后给每张图打标签。这一步千万别手工在 xls 里加行排序规则不一致会造成训练数据错位。转换完成后分别训练python train.py --model svm --labels LabelList.xls python train.py --model densenet --epochs 50 --batch-size 32 --lr 1e-4config.json 里我一般会改三个字段image_size 控制输入尺寸saved_dir 指向模型保存位置threshold 控制缺陷判定阈值。检测是二分类概率大于 0.5 判缺陷如果产线要求严把 threshold 提到 0.7宁可多误检也少漏检。训练完模型自动写到对应分支的 saved_model 目录main.py 的 show model 会去读这个目录。所以重训完不用改代码重新进 main.py 就能用新模型检测。5. 避坑与排查五个实际踩过的坑和对应解法5.1 show image 列表为空detect 报没有可选图片现象set folder photos 之后执行 show image输出空列表什么图片都看不到。 原因代码只接受 .jpg 和 .JPG 两种后缀。相机直出图经常是 .jpeg、.png或者图片从其他渠道传过来被改成了 .PNG都不会被扫描到。另一个隐蔽原因是路径写错代码找的是根目录下的 photos不是随手建的 photo。 解决先把所有图批量转成 .jpg再统一放进根目录/photospython -c from PIL import Image import glob, os for f in glob.glob(./photos/*.png): Image.open(f).convert(RGB).save(f.replace(.png, .jpg)) os.remove(f) 转完重新 show image正常应该能看到文件名列表。5.2 透视校正后分割的晶片数量和实际对不上现象一块 6x10 共 60 片的电池板分割出来只有 58 片或者多出几条细条。 原因FFT 周期估计对投影信号非常敏感。校正图边缘带着黑边时黑边会贡献一个低频强分量把主周期带偏板子上有脏污或碎裂片时灰度投影多出异常低谷频谱里出现次峰干扰。 解决先用形态学把校正图边缘裁掉再做投影其次当相邻两个峰值幅值接近时取较小的周期因为真实晶片尺寸只会被高估不会被低估。我习惯把谱里所有局部峰值对应的候选周期都列出来取 25% 分位数能基本消除黑边和脏污带来的误判。5.3 SVM 训练时内存占用暴涨甚至卡死现象train.py --model svm 跑起来没几分钟内存冲到 10GB 以上机器直接卡死。 原因RBF 核要计算两两样本的核矩阵复杂度是 O(n^2)。几千张 64x64 图提了 1764 维 HOG再乘上样本对数内存自然就爆了。 解决优先减少样本量先抽 500 片正常和 500 片缺陷来训或者换 LinearSVC 走 liblinear复杂度线性于样本数。如果一定要 RBF把 sklearn SVC 的 cache_size 设成 500 能限制缓存但治标不治本。血泪经验SVM 分支适合小样本基线数据超过两千张直接上 DenseNet别硬撑。5.4 DenseNet 验证集损失不降反升、准确率卡在 70%现象前几个 epoch 训练 loss 在降validation loss 从 epoch 10 开始一路往上走。 原因两个常见原因叠加。一是输入尺寸和分割 cell 实际分辨率差距太大cell 才 100x100 却 resize 到 224x224栅线细节被抹糊二是学习率没配动1e-3 对预训练模型来说太大权重一更新就把预训练学到的特征冲掉了。 解决输入尺寸设成和 cell 原始分辨率同量级128x128用预训练权重并冻结 backbone 前两层只训后面的 dense block 和分类器for name, param in model.named_parameters(): if name.startswith(features.denselayer1) or name.startswith(features.denselayer2): param.requires_grad False学习率从 1e-4 往下调配合早停 patience10验证集连续 10 个 epoch 不涨就停。这套组合我实测把一版卡在 72% 的模型提到了 91%。5.5 xls 标签和图片顺序错位模型学到的是噪声映射现象SVM 训练完 acc 奇高但 detect 时表现稀烂后来发现训练时正常片和缺陷片的标签对调了。 原因LabelList.xls 或 FinalLabel.xls 被手工编辑过行顺序和分割图片的文件名顺序不一致。脚本按文件名排序读图如果表格按其他顺序写第 i 行图片用的实际是第 j 行的标签。 解决重训前先做对齐检查——用 pandas 读 xls 后强制按文件名排序再和图片列表比对行数import pandas as pd df pd.read_excel(LabelList.xls) assert list(df[filename]) sorted(df[filename]), 标签文件未按文件名排序 assert len(df) len(picture_list), 标签行数和图片数不一致从那以后我每次重训前都强制跑一遍这个断言数据错位问题一旦发生复盘成本非常高。6. 从单张检测到批量验证automove 和评估脚本怎么用6.1 automove.py 批量移动缺陷片单张检测只能验证流程通不通。产线场景里你面对的是几百张原图每张分割出几十片总共上万张晶片小图要过一遍模型。automove.py 就是干这个的——读取分割输出目录把所有被判为缺陷的晶片小图移动到指定目录方便人工复检或做缺陷样本库。python automove.py --source ./output/panel_001_segmented \ --defect ./defect_samples \ --normal ./normal_samples移动而不是复制的好处是移动后的目录结构直接可以当下一轮训练的标注数据。缺陷样本库滚两轮之后你就攒下了一批真实场景下的正样本比从网上找素材靠谱得多。6.2 用 xls 结果文件算召回率和精确率detect 之后程序会把每张晶片小图的预测结果写回 xls配合 FinalLabel.xls 里的真实标签就能做评估。缺陷检测盯的不是准确率而是召回率——漏掉一个隐裂片比多报十个好片代价大得多import pandas as pd df pd.read_excel(FinalLabel.xls) tp ((df[predict] 1) (df[label] 1)).sum() fp ((df[predict] 1) (df[label] 0)).sum() fn ((df[predict] 0) (df[label] 1)).sum() precision tp / (tp fp 1e-8) recall tp / (tp fn 1e-8) f1 2 * precision * recall / (precision recall 1e-8) print(fprecision{precision:.3f} recall{recall:.3f} f1{f1:.3f})如果 recall 低于 0.9我会先回查失败样本是分割环节把缺陷片切碎了还是模型把有缺陷的栅线区域误判成正常纹理。前者是预处理问题改 margin 和网格对齐后者才是模型问题加训练数据、调 threshold。6.3 评估脚本保持不动是最重要的习惯最后说一个自己的习惯评估脚本一旦写好就冻结不在调试过程中顺手改。很多时候调参调得难受开始怀疑阈值设得不对、怀疑指标算错了于是动手改评估代码结果指标变好了其实只是把评估标准改松了。这种自我欺骗在缺陷检测里最致命产线不会因为你改脚本就放过缺陷片。从那以后我每次训练、调参、换模型都强制走同一份评估脚本、同一份留出测试集只允许动训练侧参数。测试集是最后一道关任何针对它做的调整都会让线下指标失去意义。希望这份代码和这些坑能帮你在光伏缺陷检测上少走几步弯路把时间花在真正影响检测质量的环节上。本文还有配套的精品资源点击获取