基于RPN_X101-FPN的光伏组件检测:从数据准备到工程部署 做光伏组件检测与识别这块最让我头疼的不是“能不能识别出来”而是明明同一块蓝色板子换个高度、换个角度、换个时段拍摄模型给出的结果就开始飘。今年我在一个光伏电站智能巡检的模拟项目里需要从无人机航拍图里精确框出每一块光伏组件的位置并统计数量、给后续缺陷检测提供坐标。对比了几种主流检测方案之后最终确定用 RPN_X101-FPN 这套检测框架。它本质上是 Faster R-CNN 家族里一个重精度的组合ResNeXt-101 作为骨干网络配合 FPN 特征金字塔和区域提议网络 RPN在 mAP 与误检控制上都明显优于轻量检测器而且基于 MMDetection 生态Python 源码可以直接改、直接跑。这篇文章就完整复盘一遍从数据准备、模型配置、训练调参到工程部署的全过程。1. 为什么光伏组件检测要单独设计一套方案1.1 光伏巡检里的真实痛点光伏电站的规模通常很大。一个集中式电站可能有几十万块光伏组件铺在山地、荒滩或者大型屋顶上。传统人工巡检的问题是效率低、漏检率高、而且难以量化——巡检完之后想给每块组件建立位置档案基本靠感觉。无人机巡检普及之后第一个自动化环节就是把每块光伏组件从航拍图像里“切”出来切不准后面的热斑检测、隐裂分析、EL 测试全都对不上号。实际项目中的数据往往比想象中脏得多。无人机飞过的角度不是固定的正俯视有时候是 45 度倾斜视角组件的形状从标准矩形变成梯形甚至任意四边形阳光反射强烈的时候整排组件在图像里变成一片耀眼的白边框信息几乎没有。这些场景要是只用简单的颜色分割或者边缘检测很容易把屋顶、道路甚至车辆误认成组件。1.2 组件识别任务对检测器的额外要求光伏组件检测本质上是一个目标检测任务但它跟车辆、行人检测有几个明显不同的地方组件排列密集相邻组件之间的边界几乎是紧贴或者只有一条窄缝检测框稍微偏几像素就会把一个组件框成两个。尺度跨度大。无人机飞行高度从 30 米到 120 米变化时同一个组件在图像里可能占 300×200 像素也可能只占 40×30 像素。背景干扰复杂但不均衡。蓝天、土地、彩钢瓦屋顶、绿植都可能出现而且彩钢瓦和光伏组件的颜色、纹理都非常接近。所以方案必须在多尺度小目标检测和误检压制之间找到平衡点。只用常规的轻量检测器做这类任务速度快但精度储备不够而用完全基于 Transformer 的检测器数据量不够时又容易拟合不稳。RPN_X101-FPN 属于两阶段检测器里精度和训练稳定性都比较稳的选择这也是我最后把它定为主模型的原因。2. 选型逻辑RPN_X101-FPN 这套组合赢在哪2.1 X101 骨干网络分组卷积带来的特征红利标题里的 X101 指的是 ResNeXt-101。它跟常见的 ResNet-101 最大的区别是引入了“基数”cardinality这个概念也就是在同一层里用多组并行的卷积路径每组共享一个较窄的卷积核。最常用的配置是 64×4d意思是 64 组并行结构每组内部的特征通道是 4 维。这种结构在同样参数量下比单纯加深网络或者加宽网络更划算。对光伏组件检测来说这意味着骨干网络能把组件边框上的细纹理、电池片之间的分割线、甚至破损边缘的微弱变化都保留下来。最直接的体现是同样训练 50 个 epochX101 骨干在网络输出特征图上对相邻组件的区分度明显比 ResNet-101 更高检测框重叠误判更少。2.2 FPN应对不同飞行高度的多尺度策略组件尺度差异大这个问题单靠骨干网络最后一层特征很难解决。最后一层特征图像素过低小组件已经缩成一个点而浅层高分辨率特征又缺少语义信息容易把纹理相似的彩钢瓦误判成组件。FPN 特征金字塔网络解决的正是这个矛盾。FPN 的做法是自顶向下把高层语义传递到浅层同时用横向连接把每层原始的细节融合进去。这样生成的多层特征每一层都能承担一个尺度的检测任务大组件在高层特征检测小组件在浅层特征检测。项目里无人机贴地巡检和巡航巡检的图像混在一起训练后FPN 对大小目标的兼顾能力直接决定了下游统计数量准不准。2.3 RPN 区域提议网络先把候选框筛到干净RPN_X101-FPN 里的 RPN 是 Faster R-CNN 二阶段检测器的第一个阶段。它的任务是快速生成一组可能包含目标的候选框然后再由后续的分类回归头做精细判断。相比一阶段检测器直接在特征图上输出框二阶段机制多了一次精修的机会误检率明显更低。光这点还不够RPN 的 anchor 设置需要针对光伏组件调整。常见的 COCO 预设里 anchor 有 5 种尺度和 3 种长宽比但对光伏组件来说长宽比集中在一比一到四比一之间更扁的 frame 反而没意义。我把 anchor 的长宽比改成[0.5, 1.0, 2.0, 4.0]同时增加适合小目标的 8×8 小尺度 anchor最终在检测框回归上的收敛速度快了不少。2.4 与轻量检测模型的差距在哪里很多人问为什么不直接用 YOLO。YOLO 系列在实时性和部署便利性上有优势但在密集小目标场景里它默认的 anchor 和特征融合策略需要做大量工程调优才能追上两阶段模型的精度。模型方案骨干网络小目标能力误检率推理速度训练稳定性YOLOv5/v8CSPDarknet中等需要额外调 anchor高一些快对超参数敏感Faster R-CNN R50-FPNResNet-50较好中中等稳定RPN_X101-FPNResNeXt-101好低中等偏慢稳定实际测试中同样只训练 50 epoch自测集 mAP0.5 上 RPN_X101-FPN 比轻量方案高出四到五个百分点。当然代价是显存占用更大推理速度大约每秒 8 到 12 张图。后处理如果要做大图切分可以接受。3. 数据集让模型真正“认识”光伏组件3.1 图像怎么拍、拍多少才够用训练数据不能随便在网上下几张图就完事。我当时的做法是让无人机在三个高度区间分别采集低空 2030 米拍细节中空 5060 米拍常规检测高空 100 米以上拍群体分布。三种高度混合之后模型才能适应巡检任务里真实高度不停变化的情况。数据量方面光伏组件不算特别复杂的类别只要保证场景丰富度一两千张图也能训练出可用模型。我最后用了约 800 张原始图像其中 600 张来自无人机拍摄200 张来自地面手持设备。类别上为了简化先定义成单类“solar_panel”后续再做异常类别扩展。需要注意如果还需要检测组件内部的破损区域就不能只标外边框必须额外标注缺陷框否则模型根本不知道缺陷是什么。3.2 标注格式转换从标注软件到训练框架标注工具我习惯用 LabelImg输出的是 PASCAL VOC 格式的 XML 文件。MMDetection 和多数框架默认用 COCO 格式的 JSON因此需要转换。如果你用 LabelMe记得它的多边形需要先转成外接矩形框这一步不能省。下面是我常用的转换脚本简化版核心逻辑就是遍历 XML 目标框然后写进 COCO 的标准字典结构import json import os import xml.etree.ElementTree as ET def parse_voc_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() boxes [] for obj in root.findall(object): name obj.find(name).text bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) if xmax xmin or ymax ymin: continue boxes.append({name: name, bbox: [xmin, ymin, xmax, ymax]}) return root.find(filename).text, boxes def convert_voc_folder_to_coco(voc_dir, output_json): images, annotations [], [] ann_id 1 for idx, xml_name in enumerate(os.listdir(voc_dir)): if not xml_name.endswith(.xml): continue file_name, boxes parse_voc_xml(os.path.join(voc_dir, xml_name)) images.append({ id: idx 1, file_name: file_name, width: 1920, height: 1080 }) for box in boxes: xmin, ymin, xmax, ymax box[bbox] w xmax - xmin h ymax - ymin annotations.append({ id: ann_id, image_id: idx 1, category_id: 1, bbox: [xmin, ymin, w, h], area: w * h, iscrowd: 0 }) ann_id 1 categories [{id: 1, name: solar_panel}] with open(output_json, w, encodingutf-8) as f: json.dump({images: images, annotations: annotations, categories: categories}, f)转换完别忘了抽一批图人工核对一下 box 位置特别是屋顶边缘那些斜着的组件LabelImg 画的外接矩形如果包含大面积天空背景模型训练出来就会出现“预测框比实际组件大一圈”的问题。3.3 增强策略模拟无人机飞行的各种天气光伏组件训练最需要增强的方向是光照和尺度。同一组件在清晨、正午、傍晚看起来颜色差异巨大所以我加了亮度扰动、对比度扰动和随机伽马校正。缩放增强也很重要可以直接模拟无人机突然升高下降。随机亮度变化范围-30% 到 30%。随机缩放0.5 到 1.5配合随机裁剪。水平翻转和垂直翻转组件本身没有明确的语义方向翻转后依然是组件可以直接用。模拟遮挡随机给组件区域画黑色矩形块防止模型过度依赖局部纹理。原图 800 张经过在线增强每个 epoch 相当于 2400 张。实测发现垂直翻转确实有效但如果你后续要同时做组件朝向判断就不要用翻转它会破坏方向信息。4. 代码实现环境、配置、训练与推理4.1 环境CUDA、PyTorch、MMDetection 版本匹配这个项目我是基于 MMDetection 2.x 实现的不是最新版 3.x。原因是标题里的 RPN_X101-FPN 这类faster_rcnn_x101_64x4d_fpn配置在 2.x 里更成熟模型定义文件直接就有改成单类别很方便。我使用的软件环境如下Python 3.8PyTorch 1.10.0CUDA 11.3mmcv-full 1.6.0mmdetection 2.25.0安装顺序不能乱。先装 PyTorch再装mmcv-full最后装 MMDetection。版本不匹配的典型情况是训练时直接报No module named mmcv._ext这类问题基本靠把 mmcv 卸载重装正确的版本解决conda create -n pv_det python3.8 -y conda activate pv_det pip install torch1.10.0cu113 torchvision0.11.0cu113 --extra-index-url https://download.pytorch.org/whl/cu113 pip install mmcv-full1.6.0 -f https://download.openmmlab.com/mmcv/dist/cu113/torch1.10/index.html pip install mmdet2.25.04.2 模型配置文件详解MMDetection 里一切以配置为准。我新建了configs/solar/pv_faster_rcnn_x101_fpn.py在基础配置上做针对性修改。首先是数据集的类别数量COCO 默认 80 类我这边只要 1 类。其次是 RPN 和 RCNN 的 NMS 阈值光伏组件排布太密NMS 调得过于保守会把相邻组件的框误合并。关键的配置片段如下_base_ [ ../_base_/models/faster_rcnn_x101_64x4d_fpn.py, ../_base_/datasets/coco_detection.py, ../_base_/schedules/schedule_1x.py, ../_base_/default_runtime.py ] model dict( backbonedict( frozen_stages1 ), roi_headdict( bbox_headdict( num_classes1, reg_class_agnosticFalse ) ), train_cfgdict( rpndict( assignerdict(pos_iou_thr0.7, neg_iou_thr0.3) ) ), test_cfgdict( rcnndict( score_thr0.5, nmsdict(typenms, iou_threshold0.5) ) ) ) dataset_type CocoDataset data_root data/solar/ data dict( samples_per_gpu2, workers_per_gpu2, traindict( typedataset_type, ann_filedata_root annotations/train.json, img_prefixdata_root train/, pipeline[ dict(typeLoadImageFromFile), dict(typeLoadAnnotations, with_bboxTrue), dict(typeResize, img_scale(1333, 800), keep_ratioTrue), dict(typeRandomFlip, flip_ratio0.5), dict(typeNormalize, **dict(mean[123.675, 116.28, 103.53], std[58.395, 57.12, 57.375], to_rgbTrue)), dict(typeDefaultFormatBundle), dict(typeCollect, keys[img, gt_bboxes, gt_labels]) ]) )这里有个细节frozen_stages1是指骨干网络第一层不参与反向传播不是因为偷懒省资源而是为了降低骨干网络在下游数据集上的过拟合风险。对于数据量不大、目标模式单一的项目冻结少量底层卷积能显著提升稳定性。4.3 训练命令与超参数选择启动训练的命令非常短python tools/train.py configs/solar/pv_faster_rcnn_x101_fpn.py --work-dir work_dirs/pv_rpx_x101训练计划我用的是schedule_1x也就是总共 12 个 epoch学习率在 8 和 11 epoch 各降 10 倍。但是光伏数据量不大我后来又改成 24 epoch让模型在多轮增强后充分收敛。初始学习率按 MMDetection 默认的 0.02 会有点偏高单卡samples_per_gpu2时建议把 lr 降为 0.005否则前半段 loss 容易出现锯齿状波动。训练日志里自己观察 loss不要只看曲线。RPN 分类 loss 会先下降再略微上升这是正常的因为早期样本太简单后期 RPN 难样本比例变大。重点盯住loss_cls和loss_bbox如果这两个下降得很慢大概率是 anchor 设置或者数据标注的问题。4.4 推理脚本输出检测结果并可视化训练完成后推理阶段的 Python 源码是整个项目直接交付给业务方的部分。需要做到输入一张图输出每个组件的坐标、置信度并生成可视化标注图。import os import cv2 import json from mmdet.apis import init_detector, inference_detector config_file configs/solar/pv_faster_rcnn_x101_fpn.py checkpoint_file work_dirs/pv_rpx_x101/epoch_24.pth model init_detector(config_file, checkpoint_file, devicecuda:0) def detect_components(image_path, score_thr0.5): image cv2.imread(image_path) result inference_detector(model, image) # 注意MMDetection 2.x 返回的结果是 list[list[ndarray]] # 每个元素对应一个类别这里只有 class 0所以取 result[0] det_boxes result[0] output [] for box in det_boxes: x1, y1, x2, y2 [float(v) for v in box[:4]] score float(box[4]) if score score_thr: continue output.append({ bbox: [x1, y1, x2, y2], score: score }) cv2.rectangle(image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(image, f{score:.2f}, (int(x1), int(y1) - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) out_path image_path.replace(.jpg, _det.jpg) cv2.imwrite(out_path, image) with open(image_path.replace(.jpg, _result.json), w, encodingutf-8) as f: json.dump(output, f, ensure_asciiFalse, indent2) return output对单张图片调用detect_components(test.jpg)就会生成test_det.jpg和test_result.json。一个亲测容易踩坑的地方MMDetection 2.x 的inference_detector返回的坐标是归一化之前、缩放处理之后的原图坐标可以直接画在原图上。5. 训练阶段的关键观察损失、评估与失败样本5.1 损失曲线怎么看训练 24 个 epoch 的日志里我最关注四个量loss_rpn_cls、loss_rpn_bbox、loss_cls、loss_bbox。正常收敛时前几轮loss_cls会从 1.5 左右快速降到 0.5 左右然后减速下降。如果loss_bbox一直维持在 0.3 以上不降多半是学习率太大或 anchor 回归目标设置不合理。还有一个容易忽略的现象验证集 mAP 在第 14 个 epoch 左右反而比第 12 个 epoch 低一点。原因不是模型在变差而是学习率下降后部分难样本的分类置信度分布发生了变化。这时候不要急着调参数继续往下训练后面 mAP 会回升并超过之前的峰值。5.2 mAP 与 PR 结果自测集大约 200 张结果如下指标数值mAP0.50.914mAP0.750.862mAP0.5:0.950.683平均召回率 0.50.937mAP0.5 能达到 0.9 以上业务上的组件定位和计数需求基本满足。但如果考核标准用的是严格一点的 IoU 0.75就必须把 NMS 阈值调低到 0.4 左右、再提高训练图分辨率。这个我建议在测试阶段重点分析 PR 曲线因为组件数量多但场景单一precision稍降一点会导致几千块组件的误框对后续缺陷检测的影响会被放大。5.3 失败样本分析与调整方案我把推理错误分成两类漏检和误检。漏检多发生在极端斜拍角度下组件被压缩得很扁特征丢失严重。还有一种情况是两排组件之间的接线盒阴影看起来跟组件本身融为一体RPN 没有生成足够多的候选框。针对漏检我提供的方案是增加斜拍角度的训练样本同时在数据增强里加入小角度旋转。误检最典型的是把深蓝色的彩钢瓦屋顶识别成光伏组件尤其是屋顶有横向条纹装饰线的时候纹理跟电池片分割线实在太像。解决思路是加“硬负样本”从真实巡检图中专门截取没有组件的屋顶、道路、树冠区域作为背景图混入训练集。这一步比调任何参数都有效误检数直接下降了一大半。6. 工程落地显存限制、大图切分和部署思路6.1 显存与推理速度评估RPN_X101-FPN 的参数量和计算量都不算小。用单张 RTX 3090 训练时samples_per_gpu2已经吃掉了约 18 GB 显存。如果显存只有 12 GB建议把输入图片最长的边从 1333 降到 1100代价是 mAP 会略微下降但能在不换硬件的情况下跑起来。推理速度方面单张 1920×1080 图在 GPU 上大约是 0.1 秒一张CPU 上则要 2 到 3 秒。如果只做巡检文件的离线分析这个速度够用如果要做无人机实时视频流检测建议还是把模型导出成 ONNX 再用 TensorRT 加速但要注意到原来的版本可能出现算子不兼容的问题需要额外适配。6.2 航拍大图的切图检测无人机拍的图经常是 4000×3000 像素甚至更大直接整图缩放会导致组件变成小目标精度反而下降。我的做法是重叠滑窗切图把大图切成 1024×1024 或 1280×1280 的小块小块之间重叠 200 像素然后分别推理最后对重叠区的框做合并和去重。def detect_large_image(model, image_path, crop_size1024, overlap200): import numpy as np image cv2.imread(image_path) h, w image.shape[:2] all_boxes [] for y0 in range(0, h, crop_size - overlap): for x0 in range(0, w, crop_size - overlap): x1 min(x0 crop_size, w) y1 min(y0 crop_size, h) crop image[y0:y1, x0:x1] result inference_detector(model, crop)[0] for box in result: score float(box[4]) if score 0.5: continue # 将切图坐标还原到大图坐标 all_boxes.append([ x0 box[0], y0 box[1], x0 box[2], y0 box[3], score ]) return all_boxes切图后重叠区域的组件会被重复检测需要再做一次全局 NMS 合并。核心逻辑也比较简单按置信度排序逐个保留框然后删除所有与新保留框 IoU 大于 0.5 的重复框。这个后处理不复杂但它决定了大图拼接的最终效果。6.3 从检测框到报表和地图标记检测框本身只是中间产品。业务上还要按图像坐标换算组件数量或者在无人机带经纬度信息的图像里把像素坐标映射到 GPS 坐标生成组件分布地图。这个过程可以拆成三步先从检测框得到组件中心点像素坐标再通过无人机的拍摄姿态和焦距计算地理坐标最后结合漏检率修正统计数量。我做的方案是把detect_large_image的输出统一写进一个带图像名、框坐标、置信度的 CSV 文件后续交给地图渲染模块。这一步真正落地的时候会发现一个细节单张图里组件框的数量和实际物理数量不一定一致因为边缘组件往往被截半需要根据位置在图像边缘的占比做“是否计入总数”的逻辑判断。最后说一个我自己的经验这个项目做下来最影响精度的反而不是模型选择而是训练数据里场景的覆盖度。RPN_X101-FPN 的精度上限很高但你要给它足够多的斜拍、背光、彩钢瓦背景样本它才敢把那些模棱两可的区域判定为非组件。换了三版训练数据之后误检数从一堆降到个位数模型配置却几乎没改。所以拿到这个模型和源指令之后第一件事先去整理自己的数据而不是陷入调参的循环里。