
简介面向计算机相关专业毕业设计及深度学习实战的太阳能光伏板积灰识别项目基于Python与深度学习方法解决光伏板灰尘/积灰图像四分类问题。项目自带自制灰尘数据集涵盖普通数据增广、AutoAugment数据增强、ResNet网络及监督对比学习损失等方法适合作为毕设、课程设计或期末大作业参考。包体共241个文件以jpg图像数据集、py训练推理脚本、模型权重及相关打包文件为主另有md说明文档、xml配置等辅助内容整体约119MB已有199人学习下载。整体目录组织清晰便于按模块查阅与复用。内容包含完整源码、数据集、模型文件与说明文档便于复现实验和二次开发压缩包内还包含DenseNet121等预训练模型权重可帮助理解分类网络训练流程、对比学习损失应用及数据增强策略对需要完整深度学习项目实战的读者有较高参考价值。1. 太阳能光伏板积灰识别为什么是一个四分类问题在光伏电站的日常运维里积灰是比组件衰减更隐蔽的发电量杀手。灰尘遮挡会改变光伏板表面透光率热斑效应还会加速电池片老化但靠人工巡检一块块看几乎不现实。摄像头定点拍摄加图像识别是目前最容易被接受的无人化方案。这个项目把问题切成图像四分类清洁、轻度积灰、中度积灰、重度积灰而不是回归出积灰厚度原因很直接——现场没有标定过的厚度真值运维动作也只关心“要不要清洗”。四分类的输出能直接对接清洗计划标注成本可控也更容易用现有深度学习分类网络跑出可用的精度。这篇文章会从数据集组织、模型选型、训练调参与部署优化四条线把这个标题里的技术点拆成可以直接落地的方案。2. 项目源码里数据集的坑图像四分类的样本组织与划分策略拿到一个带数据集的源码项目第一步不是跑模型而是先看数据目录和标签定义。光伏板积灰识别的四分类边界并不像猫狗分类那么天然清晰轻度与中度之间往往只差一层灰的厚度。如果项目作者没有给出一份明确的标注规则你后续所有训练的准确性都会建立在流沙上。2.1 四分类标签定义和目录结构的常规做法常见做法是按照积灰覆盖面积或透光率损失来划分四个类别例如clean清洁、light轻度覆盖面积小于10%、medium中度覆盖面积10%到40%、heavy重度覆盖面积超过40%。但实际成像时逆光、阴影、云层反射都会影响人眼判断所以更稳健的标注依据是“光伏板栅线纹理的可见程度”。我在处理这类项目时会先把图像裁剪成只包含光伏板区域的固定尺寸再让标注员参考灰度直方图的高低频分布去归类尽量减少主观偏差。数据集的目录结构建议直接兼容 PyTorch 的ImageFolder机制也就是层级目录为train/类别名/图片文件和val/类别名/图片文件。这样做的好处是后面可以少写很多数据加载代码。需要重点检查原始 zip 包里的图片是否存在尺寸不一致、单通道灰度图混入、EXIF 旋转信息异常等问题。实际项目中我遇到过某个类别里混了几张夜间红外图训练时 loss 跳来跳去但用torchvision.datasets.ImageFolder加载时完全不会报错。因此第一件事是写一段数据清洗脚本过滤掉非正常曝光或分辨率低于阈值的图片。2.1.1 数据清洗与类别平衡检查import os from PIL import Image from collections import Counter data_root datasets/train min_width, min_height 128, 128 valid_ext (.jpg, .jpeg, .png) category_count Counter() for cls_name in os.listdir(data_root): cls_path os.path.join(data_root, cls_name) if not os.path.isdir(cls_path): continue for img_name in os.listdir(cls_path): img_path os.path.join(cls_path, img_name) if not img_name.lower().endswith(valid_ext): os.remove(img_path) continue try: with Image.open(img_path) as img: w, h img.size if w min_width or h min_height or img.mode not in (RGB, L): os.remove(img_path) continue except Exception: os.remove(img_path) continue category_count[cls_name] 1 print(category_count)这段代码会检查每张图片是否可被 PIL 正常打开、尺寸是否过小、通道模式是否合规。不符合条件的直接删掉避免它们在训练时变成 nan loss 的导火索。category_count的作用是暴露类别不平衡问题。积灰数据集的普遍现象是 clean 和 heavy 样本很多light 和 medium 相对少因为运维人员容易忽略中间态。如果四个类别的数量差距超过 3 倍就要在后续数据增强或损失函数上做补偿。2.2 图像四分类的数据划分分层抽样避免“同源泄露”光伏板积灰图片往往来自多个拍摄点位或不同时间段。如果直接随机划分训练集和验证集同一个点位在不同光照条件下的图片可能同时出现在训练和验证集里导致验证指标虚高。这个问题在图像分类里叫“同源泄露”same-site leakage在光伏场景里尤其严重因为同一块板子的背景和支架结构是固定的模型很容易偷懒去识别背景而不是积灰本身。正确做法是分层抽样按拍摄点位或采集批次作为分组单位同一组图片整体划分到同一数据桶里。如果项目源码里没有提供点位元数据可以尝试从文件名前缀或 EXIF 的 GPS 信息中提取。下面给出一个基于文件名前缀分组的划分脚本假设文件名形如site01_20231010_001.jpg其中site01是点位标识。import os import random import shutil from collections import defaultdict source_root datasets/raw output_root datasets/split train_ratio, val_ratio 0.7, 0.15 random.seed(42) grouped defaultdict(list) for cls_name in os.listdir(source_root): cls_path os.path.join(source_root, cls_name) for img_name in os.listdir(cls_path): site_id img_name.split(_)[0] grouped[(cls_name, site_id)].append(img_name) items list(grouped.keys()) random.shuffle(items) train_groups items[: int(len(items) * train_ratio)] val_groups items[int(len(items) * train_ratio): int(len(items) * (train_ratio val_ratio))] test_groups items[int(len(items) * (train_ratio val_ratio)):] for cls_name, site_id in train_groups: for img_name in grouped[(cls_name, site_id)]: src os.path.join(source_root, cls_name, img_name) dst os.path.join(output_root, train, cls_name, img_name) os.makedirs(os.path.dirname(dst), exist_okTrue) shutil.copy(src, dst)这里强调一个容易忽略的参数random.seed(42)。固定随机种子能保证重复实验时划分结果一致否则每次跑出来的验证集都不同模型对比就成了刻舟求剑。另外train_ratio设为 0.7 是在样本量中等时的折中。如果整个数据集只有几千张图片可以提高到 0.8 甚至 0.85但验证集至少要保留每个类别 50 张以上否则评估指标抖动会很大。2.3 数据增强策略光照扰动比几何变换更重要光伏板积灰识别的模型要应对一天中不同太阳高度角、云层遮挡造成的亮度变化因此单纯做随机水平翻转和裁剪还不够。我会优先加入颜色抖动和随机灰度化让模型不依赖固定的色彩分布。下面是一组适合积灰场景的torchvision.transforms配置。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomApply([transforms.ColorJitter( brightness0.4, contrast0.4, saturation0.3)], p0.8), transforms.RandomGrayscale(p0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])注意RandomGrayscale(p0.1)的概率别设太高否则模型可能被迫忽略颜色信息。积灰本身在 RGB 空间里有细微的色差完全灰度化会丢掉这部分线索。ColorJitter的亮度扰动范围取 0.4 比较安全现场摄像头曝光不稳定时甚至可以调到 0.5但对颜色饱和度的扰动不要超过 0.3。如果项目里还需要做模型对比最好把增强变换统一成同一份代码避免不同实验之间出现无关变量。3. 深度学习模型选型用预训练 ResNet 还是轻量 EfficientNet 做积灰识别四分类问题在深度学习里属于最标准的图像分类任务理论上任何卷积网络都能做。但光伏积灰识别的难点在于类别差异微妙轻度积灰与中度积灰的纹理差异可能只有几个像素级别的灰度变化。自己从头设计一套 CNN 需要大量调参性价比很低所以主流做法是迁移学习。3.1 为什么不自己写 CNN 而要用预训练权重从零训练一个 ResNet 在 ImageNet 上需要数百万张图片和大量 GPU 时间而在光伏板积灰数据集上项目源码附带的数据可能只有几千张。直接用随机初始化的深层网络很容易陷入过拟合验证集准确率卡在 70% 上下。预训练模型已经在自然图像上学会了边缘、纹理、局部形状等通用特征光伏板的栅线、灰尘颗粒分布与这些基础特征高度相关因此微调就能达到不错效果。在选型上我会优先考虑 torchvision 里的resnet34、resnet50和efficientnet_b0。ResNet 系列结构简单训练稳定适合快速验证数据质量EfficientNet 的参数效率高在算力有限时往往能比 ResNet 少用一半参数量达到相近准确率。如果部署端是边缘设备mobilenet_v3也是可选项但它在小数据集上微调需要更谨慎的学习率设置。3.1.1 构建带分类头的迁移学习模型import torch import torch.nn as nn from torchvision import models def build_model(num_classes4, model_nameresnet34, pretrainedTrue): if model_name resnet34: weights models.ResNet34_Weights.IMAGENET1K_V1 if pretrained else None model models.resnet34(weightsweights) in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(p0.3), nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(p0.2), nn.Linear(256, num_classes) ) elif model_name efficientnet_b0: weights models.EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None model models.efficientnet_b0(weightsweights) in_features model.classifier[1].in_features model.classifier nn.Sequential( nn.Dropout(p0.3, inplaceTrue), nn.Linear(in_features, num_classes) ) else: raise ValueError(fUnsupported model: {model_name}) return model这里把最后的全连接层替换成带 Dropout 的小型分类头。p0.3的 Dropout 在积灰小数据集上能明显缓解过拟合。注意resnet34的model.fc是单层线性层而efficientnet_b0的model.classifier本身已经包含 Dropout 和线性层替换时要把in_features取出来。pretrainedTrue时torchvision 会自动下载 ImageNet 权重如果网络环境不稳定需要提前手动下载权重文件并放到~/.cache/torch/hub/checkpoints下避免训练中断。3.2 冻结层与全量微调的边界什么时候冻结 backbone迁移学习有两种常见做法只训练分类头或者全量微调。光伏板积灰图像与 ImageNet 的自然图像域差异较大积灰纹理属于非常细粒度的表面特征因此完全冻结 backbone 效果通常不好。我一般会先用“冻结 backbone 只训练分类头”跑 10 个 epoch观察分类头能否收敛到合理水平然后解冻 backbone 的最后两个 stage用较低学习率继续微调。这样既能加速收敛又能避免早期梯度把预训练特征破坏掉。下面是一段解冻部分层并给不同层设置不同学习率的示例代码。def freeze_and_set_lr(model, lr_head1e-3, lr_backbone1e-4): params_head list(model.fc.parameters()) params_backbone [] if hasattr(model, layer4): # 解冻 layer4 和 layer3其余冻结 for name, param in model.named_parameters(): if name.startswith(fc): continue if layer4 in name or layer3 in name: param.requires_grad True params_backbone.append(param) else: param.requires_grad False optimizer torch.optim.AdamW([ {params: params_backbone, lr: lr_backbone}, {params: params_head, lr: lr_head} ], weight_decay1e-4) return optimizer这里用layer4和layer3作为解冻范围是因为 ResNet 的浅层提取的是颜色和边缘深层提取的是语义特征。积灰分类中的核心特征“纹理覆盖程度”恰好由中高层捕捉所以从 layer3 开始解冻是收益较高的选择。lr_head1e-3给新初始化的分类头用比较大的学习率lr_backbone1e-4给预训练参数用较小的学习率防止破坏原有特征。weight_decay1e-4是针对小数据集的常规设置过大容易让 backbone 权重退化过小则分类头过拟合。3.3 图像四分类的损失函数选择交叉熵还是基于距离的损失默认使用nn.CrossEntropyLoss是最稳妥的。不过积灰四分类的类别之间存在天然的顺序关系清洁 轻度 中度 重度交叉熵完全无视这个顺序。如果模型经常把轻度和中度混淆可以尝试把输出从四分类变成“顺序回归问题”的变体例如使用 ordinal loss但这会增加代码复杂度。一个更简单的折中方案是在交叉熵基础上加上一个正则项惩罚预测类别与真实类别的绝对差。示例如下。class OrdinalCrossEntropy(nn.Module): def __init__(self, num_classes4, penalty0.1): super().__init__() self.base_loss nn.CrossEntropyLoss() self.penalty penalty def forward(self, logits, targets): ce_loss self.base_loss(logits, targets) probs torch.softmax(logits, dim1) preds torch.argmax(probs, dim1) abs_diff torch.abs(preds - targets).float().mean() return ce_loss self.penalty * abs_diffpenalty0.1表示如果预测结果比真实类别偏差一个等级loss 额外增加 0.1。这个值不宜过大否则模型会变得保守倾向于预测中间类别来降低风险。实测中这类损失函数能把轻度与中度的混淆率降低两三个百分点代价是纯净类别之间的准确率略有下降。如果你做的是比赛或项目基线验证建议先只跑交叉熵把混淆矩阵分析清楚后再决定是否加顺序约束。4. 训练评估与常见坑从 PyTorch 代码到光伏积灰场景的调参实战模型写好后训练脚本是整个项目的发动机。这一章会给出一个完整的 PyTorch 训练循环骨架并拆解光伏积灰场景里最容易踩的坑。很多拿到源码项目的同学喜欢直接python train.py跑起来但训练日志里没有记录每组实验的超参数后期连自己都看不懂模型为什么好、为什么坏。4.1 最小可运行的训练循环与学习率调度下面这段代码实现了基本的训练、验证和 checkpoint 保存逻辑。注意这里没有使用任何第三方工具库只依赖 PyTorch 自带功能方便你整合进项目源码。import torch import torch.nn as nn from torch.utils.data import DataLoader from tqdm import tqdm def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total 0, 0, 0 for images, labels in tqdm(loader, desctrain): images, labels images.to(device), labels.to(device) optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total torch.no_grad() def validate(model, loader, criterion, device): model.eval() total_loss, correct, total 0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) logits model(images) loss criterion(logits, labels) total_loss loss.item() * images.size(0) preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total def run_training(model, train_loader, val_loader, epochs30, devicecuda): criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) best_acc 0 for epoch in range(epochs): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device) val_loss, val_acc validate(model, val_loader, criterion, device) scheduler.step() print(fepoch{epoch:02d} train_loss{train_loss:.4f} ftrain_acc{train_acc:.4f} val_loss{val_loss:.4f} fval_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt) print(fbest_val_acc{best_acc:.4f})CosineAnnealingLR的T_max设为总训练轮数这样学习率会在首尾两端变化平缓、中间变化较快比固定学习率在小数据上更不容易跳出最优区域。torch.save只保存模型权重而不保存完整模型这是惯例因为后续要加载权重但可能修改分类头结构。注意这里验证集没有做任何数据增强只做了 Resize 和 Normalize否则验证指标会被增强带来的噪声干扰。4.2 光伏积灰项目的 3 个必调参数图像尺寸、Batch Size 和 Epoch这三个参数在源码项目里往往不是给不同硬件准备的拍脑袋设了也能跑但效果完全不同。先说图像尺寸光伏板积灰的纹理属于细节特征把图像缩到 128x128 会丢失太多信息我建议至少 224x224有条件可以上 256 或 384。但尺寸太大会让训练变慢且摄像头采集的原图往往带有大量天空和地面背景直接 Resize 到 224 会降低有效分辨率。因此预处理时应先裁剪出光伏板区域再做 Resize。Batch Size 的选择要看显存。ResNet34 在 224x224 下batch size 32 大约占 6GB 显存。如果显存不够不要只调 batch size还要考虑accumulate_grad_steps模拟更大 batch。下面是一个参数速查表基于单卡训练经验值。配置项推荐范围低数据量(2000)高数据量(10000)输入图像尺寸224 / 256224256Batch Size16 / 321632初始学习率1e-4 ~ 3e-41e-43e-4冻结骨干训练轮数10105全量微调轮数20-403020Label Smoothing0 或 0.10.10表中“低数据量”指每个类别只有几百张图片。此时 label smoothing 设在 0.1 能让模型不过分自信对轻度和中度的边界判定更平滑。但 label smoothing 不是直接用CrossEntropyLoss参数时要注意PyTorch 的nn.CrossEntropyLoss(label_smoothing0.1)从 1.10 版本开始原生支持如果你的项目环境版本较老需要手动实现平滑标签否则会报类型错误。4.3 混淆矩阵与误报分析积灰识别的准确率幻觉在光伏积灰识别里整体准确率 95% 以上并不代表模型可用。如果 clean 和 heavy 占样本大多数模型只要在这两类上表现好准确率就能刷得很高但 light 类可能几乎全错。运维系统真正关心的是“heavy 被漏掉”因为漏报意味着该清洗的板子没洗影响发电收益。因此必须输出四类别的混淆矩阵而不是只打印一个准确率。from sklearn.metrics import confusion_matrix, classification_report import numpy as np def evaluate_full(model, loader, device, class_names): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in loader: images images.to(device) logits model(images) preds logits.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) report classification_report( all_labels, all_preds, target_namesclass_names, digits3) print(cm) print(report) return cm对这份输出重点看两个指标一是“重度的召回率”即所有真实重度样本中有多少被正确预测为重度二是“清洁样本被误判为轻度的比例”。这两个指标直接影响清洗决策的精准度。如果混淆矩阵显示中等积灰与轻度积灰互相混淆严重优先怀疑的不是模型容量而是标注边界本身是否足够清晰。这时可以回头调整标签定义例如把中度积灰的划分阈值从“覆盖面积大于40%”改成“覆盖面积大于30%”重新标注后再训练。4.4 训练时最常见的 3 个环境与数据坑第一个坑是DataLoader的num_workers设置。在 Windows 环境下如果num_workers 0且训练循环不是写在if __name__ __main__:保护块里会报 RuntimeError。Linux 服务器上则要小心num_workers太高导致内存溢出。建议先设成 2确认代码能跑通后再调大。第二个坑是数据集类别不均衡直接训练。如果 light 类只有 clean 类的五分之一默认 PyTorch 的 sampler 会按顺序采样导致每个 epoch 里 light 看到次数少。使用WeightedRandomSampler可以按样本数量反比设置权重让每个类别被采样的概率大致相同。这段代码可以放在 DataLoader 之前。from torch.utils.data import WeightedRandomSampler def make_balanced_sampler(labels, num_samples_per_epochNone): label_array np.array(labels) class_counts np.bincount(label_array) weights 1.0 / class_counts[label_array] num_samples num_samples_per_epoch or len(labels) sampler WeightedRandomSampler(weights, num_samples, replacementTrue) return sampler第三个坑是预处理归一化与模型权重不匹配。很多项目源码里直接把图片除以 255 后送入ImageNet预训练模型但没做Normalize。这样模型看到的输入分布与预训练时完全不同特征提取器会输出奇怪的结果。torchvision 官方推荐用mean[0.485, 0.456, 0.406]和std[0.229, 0.224, 0.225]这一点在 2.3 节已经强调过但在实际训练脚本里是最容易被忽略的一行。5. 模型部署时的置信度过滤与增量优化技巧训练完的模型要真正落地到光伏电站的摄像头前不能直接拿准确率说事。现场画面里会有无人机巡飞的拍摄视角、雨天积水的高光反射、早晚时段的整体偏色这些在离线验证集里很难完全覆盖。常见的部署做法是把 PyTorch 模型导出成 TorchScript 或 ONNX用 CPU 或边缘 NPU 推理。但导出只是第一步真正决定项目可用性的是置信度过滤策略。光伏积灰四分类模型在遇到没见过的新场景时softmax 输出往往仍然非常自信。比如一张雨后被泥水覆盖的板子模型可能以 0.9 的概率给出 heavy 类别但这和真实积灰的清洗决策完全不同。这时我建议加一个“低置信度拒识”分支。定义一个阈值rejection_thresh当最高 softmax 概率小于该阈值时输出 unknown 类别让运维系统提醒人工复核。def predict_with_rejection(model, tensor, reject_thresh0.7): model.eval() with torch.no_grad(): logits model(tensor.unsqueeze(0)) probs torch.softmax(logits, dim1) max_prob, pred_idx torch.max(probs, dim1) if max_prob.item() reject_thresh: return -1, max_prob.item() return pred_idx.item(), max_prob.item()reject_thresh怎么定可以拿验证集里未知场景比如加了模拟雨滴的图片做一个 10 分钟的小实验画出置信度分布曲线找出真实样本和异常样本置信度重叠最少的点。通常 0.6 到 0.8 之间。阈值过高会大量拒识过低的阈值等于没有拒识。实际项目中我会把 unknown 类单独计数并定期收集这些低置信度样本用半自动标注的方式加入训练集——这就是增量优化最实用的一环。增量优化时不要再从零训练。常见做法是把旧模型作为起始权重用新采集的样本做全量微调学习率设为 1e-5 到 3e-5训练轮次控制在 5 到 10 轮。为了避免模型在增量数据上发生灾难性遗忘可以按旧数据新数据 4:1 的比例混合采样或者用知识蒸馏把旧模型的 soft label 作为辅助监督。这样每过一周或一个月更新一次模型积灰识别的泛化能力会随运维数据积累越变越稳。另外一个提高现场识别率的技巧是使用多帧投票而非单帧输出。固定摄像头拍摄时取连续 5 到 10 帧的预测结果按类别统计频次取最高频类别作为最终结果。这能过滤掉飞鸟经过、落叶飘过引起的瞬态误判。执行这段逻辑时只需把多个帧的pred_idx收集到一个列表里用以下代码做投票。from collections import Counter def vote_predictions(pred_list): counter Counter(pred_list) return counter.most_common(1)[0][0]投票窗口的长度需要按摄像头处理帧率来调。如果每秒只能处理一帧5 帧投票会产生 5 秒延迟对清洗决策而言完全可以接受但如果要做实时报警就要缩短到 3 帧。后处理逻辑和置信度阈值一样都属于不需要重训模型就能获得精度增益的廉价手段。把这条链路串起来整个项目就不再只是给论文贡献一张准确率表而是能真正变成一个光伏电站的积灰清洗决策工具。本文还有配套的精品资源点击获取