第一章 开篇:为什么太阳能电池板需要一套能"看见"问题的检测系统
这两年光伏行业的装机量涨得有多猛,相信不用我多说了。屋顶分布式、地面电站、农光互补,各种场景铺天盖地。但电站建起来只是第一步,真正让人头疼的是后面的运维。一块板子出了问题,在几百亩的电站里找出来,跟大海捞针差不多。
我接触过不少做电站运维的朋友,他们最常吐槽的场景是这样的:早上六点背着红外检测仪进场,一块一块板子人工巡检,一天下来走断腿也只能看几百块,而且全凭肉眼和经验判断,裂纹、热斑、隐裂、脏污遮挡,不同缺陷的判据完全不一样,新来的运维人员没有两年经验根本不敢独立下结论。更要命的是,高空组件、陡坡阵列这些位置,人根本过不去。
所以当我看到"基于深度学习的太阳能电池板检测系统"这个课题方向时,第一反应是:这确实是刚需。它不是那种为了写论文而写的空中楼阁,而是能实实在在落地到电站运维流程里的工具。
这套系统做的事情概括起来很简单:用目标检测模型识别电池板上的各类缺陷,再通过 Web 界面让运维人员上传图片、查看结果、管理记录。技术栈也很清晰——YOLO 系列模型负责"看见"缺陷,Django 负责把模型能力包装成一个人人能用的系统。
为什么选 YOLO?因为检测速度足够快,在 GPU 上跑实时视频流都没问题;模型体积相对可控,部署到普通工作站甚至边缘设备都有戏;而且 v5、v8、v11、v12 这几个版本迭代下来,无论是精度还是部署生态都已经相当成熟。配合 Django 做 Web 层,不用单独写前端,自带的管理后台、ORM、用户认证全是现成的,非常适合快速搭建一套可用的业务系统。
这篇文章适合谁看?如果你正在做类似的毕业设计或课题,比如目标检测方向、Web 应用方向,这套思路可以直接参考;如果你是电站运维的从业人员,想了解深度学习到底能在巡检场景里做什么,里面关于数据采集和效果评估的部分会给你一些参考;如果你只是对 YOLO 和 Django 怎么配合感兴趣,那从模型训练到系统部署的完整链路,这篇文章基本都覆盖了。
接下来我不打算按"项目背景、技术介绍、系统实现"这种教科书顺序讲,而是按照我当时从零到一把这套系统做出来的顺序,把真正关键的环节、踩过的坑、验证过的经验一个个拆开说。
2. 第一步不是选模型,而是先把检测目标定义清楚
很多人拿到这类课题,第一反应是先去把 YOLO 最新版下载下来,跑个 Demo 再说。我一开始也差点这么干,但后来发现,如果连"要检测什么"都没定义明白,后面每一步都会被返工拖死。
2.1 电池板缺陷检测到底检测哪几类东西
太阳能电池板的缺陷种类很多,但真正在目标检测任务里能通过可见光图像识别、又有实际运维价值的,我梳理下来大概是这几类:
| 缺陷类型 | 可见特征 | 实际危害 | 检测难度 |
|---|---|---|---|
| 热斑 | 局部区域颜色异常偏暗/偏亮(可见光下有时不明显) | 永久性损坏,引发火灾隐患 | 中 |
| 隐裂 | 细线状裂纹,方向随机 | 影响发电效率,随时间扩大 | 高 |
| 脏污遮挡 | 鸟粪、灰尘、落叶等覆盖物 | 遮挡区域无法发电,形成热斑 | 低 |
| 玻璃破碎 | 明显裂痕、碎纹 | 直接暴露电池片,漏电风险 | 低 |
| 组件位移/变形 | 边框弯曲、板面倾斜 | 结构安全隐患 | 中 |
这里有个容易踩的误区:很多人把"热斑检测"直接当成目标检测问题做,但热斑在可见光图像上往往不明显,真正的热斑检测要用红外图像。那这套系统为什么还把它列进来?因为实际场景里,一块板子因为局部遮挡或隐裂,在可见光下也会表现出明显异于周围的色差,这种"疑似热斑区域"作为预警项是有意义的。换句话说,我们检测的目标不是物理意义上的热斑,而是"可能在可见光下表征热斑风险的异常区域"。
把检测目标定义成"可见光可识别的异常区域"而不是"缺陷本身",这个思路上的转变很重要,它直接决定了你后续标注数据时采用什么标准、模型训练时怎么设置类别。
2.2 缺陷检测和普通目标检测的差异点
用 YOLO 检测电池板和检测行人有很大区别。行人检测,目标通常占据画面较大比例,轮廓清晰,类别差异大。电池板缺陷正好相反——缺陷往往很小,有的甚至只有几个像素宽;背景高度相似,整张图全是深蓝色的电池板纹理;类别之间外观差异小,脏污和热斑在局部区域内可能长得差不多。
这个特点决定了我们不是简单套用 YOLO 的默认配置就行,而是要在后面几个环节专门针对"小目标""相似类别"做调整。主线思路是:
- 数据层面:裁剪原图分块训练,相当于让模型"凑近看"。
- 模型层面:选择带有小目标检测头的版本,或者调整 anchor 尺寸。
- 训练层面:mosaic 增强改成小范围版本,避免小目标被过度裁剪丢框。
这些细节我在第四章里会展开讲。这里先记住结论:太阳能电池板缺陷检测的关键难点不在模型结构,而在数据定义和处理策略。
3. 数据集:从裸图到能训练的标注集
说实话,开题时我觉得整个项目里最费时间的环节是训练模型,做完才发现,数据准备才是真正的大头。一块板子的原始巡检照片,要变成 YOLO 能用的训练集,中间至少经过采样、清洗、裁剪、标注、增强五道工序。
3.1 图片来源和采集要点
我们没有公开的现成数据集可以用,只能自己采集。主要来源是几个光伏电站的巡检照片,另外在网上下载了一部分公开的组件图像作补充。如果你也要做类似项目,采集阶段有几个建议值得参考:
- 尽量覆盖不同光照条件。上午顺光、中午强光、傍晚弱光下的组件外观差异非常大,模型只在单一光照下训练,换场景基本就废。
- 拍摄角度要有多样性。无人机航拍和地面手持拍摄的视角完全不同,同一块缺陷在两种视角下形状差异很大。
- 分辨率宁高勿低。原始图片建议 2000 万像素以上,因为后面要裁剪分块,分辨率不够的话裁剪完细节全丢了。
- 每类缺陷至少要有 300 个以上的实例框。太少的话模型根本学不到该类别的有效特征。
这里插一句,如果实在找不到足够多带缺陷的真实图片,有个取巧的办法是"健康板 + 缺陷合成"。拿干净组件图像做底图,把从其他图上抠下来的缺陷贴上去,做形态、透明度、光照的随机变换。这种合成数据训练出来的模型,精度上限不如全真实数据,但作为冷启动是完全够用的。我们实测中,合成数据占训练集 30% 以内时,对最终精度的影响在可接受范围内。
3.2 标注工具和标注规范
标注工具用的 LabelImg 和 LabelStudio 都行。我最后选择的是 LabelStudio,原因有两个:一是它支持在浏览器里多人协作标注,后面如果找了同学帮忙标数据不用每台电脑装环境;二是它导出 COCO 格式方便,转 YOLO 格式只需要写个小脚本。
比工具更重要的,是标注规范。同一张图上,两个人标出来的框可能差很多,这会直接干扰模型收敛。我们当时定了几条铁律:
- 缺陷区域用矩形框,框必须紧贴缺陷外缘,允许有 10% 以内的余量,但不允许框大范围包含健康区域。
- 一个缺陷只标一个框,禁止用多个小框拼一个大缺陷。
- 有歧义的区域,比如"疑似脏污但可能是阴影",统一标注为脏污类,宁严勿松。
- 碰到一块板上同时有多个缺陷,全部标记,不许偷懒只标最明显的。
规范定完之后,我把所有标注结果做了一次交叉抽检,抽了 20% 的图让另一人重新标,计算两类标注的 IoU,低于 0.7 的图退回重标。这一步花了两天时间,但直接决定了后面模型精度的天花板。
3.3 裁剪策略和 YOLO 格式转换
原始图片太大,直接丢进 YOLO 训练,且不说显存扛不扛得住,缺陷目标在原图中的占比太小,模型基本上学不到特征。我采用的是滑窗裁剪:把每张大图切成 640×640 的小块,相邻块之间设置 20% 重叠,避免缺陷正好被切在边界一分为二。
裁剪完之后做了一次筛选,只有包含至少一个标注框的块才保留下来。这样做的好处是训练时每一张图都有正样本,不会在大量纯背景图上浪费计算。代价是模型没见过多少"空背景"图,推理时容易在纯背景区域产生误检。解决方案是在训练集里补充 15% 左右的纯背景块,让模型知道"没有东西的时候要输出空"。
标注框做坐标变换的时候有个细节容易出错:原图中的框坐标是相对于原图的,裁剪后必须换算成相对于裁剪块的坐标,而且裁剪块超出原图边界的部分要用灰边填充,不能直接丢弃锚框。我一开始写脚本时忽略了这一点,导致一批框的坐标全部偏移,白训了一个晚上。
转换脚本核心逻辑如下:
import os import cv2 import json import numpy as np from glob import glob def crop_and_convert(coco_json, img_dir, save_dir, crop_size=640, overlap=0.2): with open(coco_json, 'r') as f: data = json.load(f) # 建立 category id 到 yolo class id 的映射 cat_map = {c['id']: i for i, c in enumerate(data['categories'])} for img_info in data['images']: img_path = os.path.join(img_dir, img_info['file_name']) img = cv2.imread(img_path) H, W = img.shape[:2] annotations = [ann for ann in data['annotations'] if ann['image_id'] == img_info['id']] step = int(crop_size * (1 - overlap)) for y in range(0, H, step): for x in range(0, W, step): x2 = min(x + crop_size, W) y2 = min(y + crop_size, H) # 边界处不够尺寸,用灰色填充 crop = np.full((crop_size, crop_size, 3), 114, dtype=np.uint8) crop[:y2-y, :x2-x] = img[y:y2, x:x2] # 收集落在裁剪框内的目标 labels = [] for ann in annotations: bx, by, bw, bh = ann['bbox'] cx, cy = bx + bw/2, by + bh/2 if x <= cx < x2 and y <= cy < y2: # 中心点落在裁剪框内的目标才保留 nx = (cx - x) / crop_size ny = (cy - y) / crop_size nw = bw / crop_size nh = bh / crop_size labels.append(f"{cat_map[ann['category_id']]} {nx:.6f} {ny:.6f} {nw:.6f} {nh:.6f}") # 只保存含目标的裁剪块(可按需保留部分空背景块) if len(labels) > 0: crop_name = f"{img_info['file_name'].split('.')[0]}_{x}_{y}.jpg" cv2.imwrite(os.path.join(save_dir, crop_name), crop) with open(os.path.join(save_dir, crop_name.replace('.jpg', '.txt')), 'w') as f: f.write('\n'.join(labels))中心点落在裁剪框内的目标才保留,这个策略比"框和裁剪块有重叠就保留"更稳妥。原因在于,重叠保留会导致同一个缺陷被多个裁剪块各标一半,模型受到的监督信息是矛盾的。
4. YOLO 版本选型:v5/v8/v11/v12 到底怎么选
YOLO 这几年版本号跟坐火箭一样往上涨,v5、v8、v11、v12 各有拥趸。我的建议是:不要盲目追新,按照自己的硬件条件和任务难度选。
4.1 四个版本的核心差异梳理
先给一个比较直观的对比表,列的是我在同一批数据、同一块显卡(某 8GB 显存级 GPU)上的实测表现:
| 版本 | 网络结构特点 | mAP@0.5 | 单张推理耗时 (ms) | 模型体积 (MB) | 生态成熟度 |
|---|---|---|---|---|---|
| YOLOv5 | C3 结构,老牌稳定 | 0.732 | 6.8 | 27 | 最高,资料最多 |
| YOLOv8 | C2f 结构,带 Anchor-Free 头 | 0.758 | 7.1 | 42 | 高,文档清晰 |
| YOLO11 | 改进 C3k2,速度精度均衡 | 0.771 | 6.5 | 38 | 中等,迭代快 |
| YOLO12 | 注意力机制增强,精度上限高 | 0.782 | 8.9 | 55 | 较新,资料少 |
这张表的结论是:v5 虽然老,但做毕设或小规模项目完全够用,遇到问题全网都能找到答案;v8 是"什么都还行"的水桶选手,我最终选择的就是它;v11 是 v8 的改进版,如果直接从 v8 迁移过来几乎没有学习成本;v12 加了注意力机制,精度最高但推理速度也最慢,更适合离线分析场景。
4.2 我为什么最终选了 YOLOv8
说实话,最开始我跑的是 v5,因为网上教程最多,遇坑容易搜。但后来发现两个问题:一是 v5 的 Anchor-Based 检测头对太阳能电池板这种小尺寸缺陷不太友好,需要手动调 anchor 参数;二是 v5 的工程结构偏老,和后面接 Django 部署时,API 设计不如 v8 简洁。
换到 v8 之后最直接的感受是:一切都有默认值,Detect 头支持 Anchor-Free,训练命令一行搞定:
yolo detect train data=dataset.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16而且 v8 的 Python 接口设计很干净,推理只需要几行代码:
from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") results = model("一个.jpg", conf=0.35, iou=0.45)这个简洁性在后期封装 Django 服务时价值很大,省掉了大量胶水代码。所以我的建议是:如果你追求稳妥和省心,无脑选 v8 或者同家族的 v11 就行;如果你的课题明确要求"最新模型",再考虑 v12。
4.3 基于同一数据集的实测对比过程
为了搞清楚到底哪个版本适合我们的任务,我做了一次相对严谨的对比实验。固定同一份训练集、同一份验证集,只改模型版本,训练 100 轮,每一轮用相同的随机种子。这里特别强调随机种子,是因为 YOLO 训练过程带有随机性,不固定种子的话两次训练的差异可能比换模型的差异还大,对比就失去了意义。
实验跑下来的几个值得注意的发现:
v5 训练收敛速度最快,30 轮左右指标就基本稳定了,但是最终精度确实垫底。v8 在 50 轮左右有明显的一次精度跳升,推断是 Anchor-Free 头和 C2f 模块配合下,网络在这个迭代点学会了更好的特征组合。v12 前期训练很慢,要 70 轮之后才开始超过 v8,这说明注意力模块需要更长的训练才能发挥作用,如果你的算力有限,v12 不一定划算。
在太阳能电池板缺陷检测这个具体任务上,v8 和 v12 的差距只有约 2.4 个百分点的 mAP,但推理速度差距却接近 37%。对于需要处理大量巡检图片的 Web 系统来说,这个速度差可能意味着用户等 1 秒还是等 2 秒的区别,我最终选了 v8。这也回应了最初的问题:模型选型不是选"最好的",而是选"最适合你的算力和场景的"。
5. 训练环节:配置、调参与收敛判断
训练是看起来最"无脑"的一步——装好环境、敲一条命令、等它跑完。但要让模型真正在电池板缺陷上达到可用水平,有几个点必须专门处理。
5.1 数据配置文件和超参数设置
训练第一步是把数据集结构告诉 YOLO。我的数据集目录长这样:
datasets/solar/ ├── images/ │ ├── train/ # 1800张 │ └── val/ # 260张 ├── labels/ │ ├── train/ │ └── val/ └── solar.yamlsolar.yaml内容如下:
path: datasets/solar train: images/train val: images/val nc: 5 names: ['hotspot', 'crack', 'dirt', 'broken', 'deformed']这里有个和通用目标检测不一样的坑:太阳能电池板缺陷的类别严重不平衡。脏污类占了数据量的将近一半,裂纹类只有十分之一。如果不处理,模型会倾向于把所有不确定的框都预测成脏污。YOLO 提供了每个类别单独设置 loss 权重的参数,但更简单的做法是先按原始分布训练,看各类别 AP 差距再决定要不要加权。
超参数方面,我建议按照这个基准值起步:
| 参数 | 基准值 | 说明 |
|---|---|---|
| imgsz | 640 | 训练输入尺寸 |
| batch | 16 | 8GB 显存的安全值 |
| epochs | 100 | 足够让模型收敛 |
| lr0 | 0.01 | v8 默认值,不需要乱调 |
| mosaic | 0.5 | 默认 1.0,对缺陷类小目标建议降低 |
| close_mosaic | 10 | 最后 10 轮关闭 mosaic,让模型适应真实分布 |
mosaic 增强这里要特别解释一下。YOLO 默认的 mosaic 会把 4 张图拼在一起随机裁剪缩放,这对检测大目标是好事,但对电池板缺陷这种小目标反而是灾难——一个本来就只有几十像素的裂缝,经过 mosaic 缩放后可能只剩十几个像素,基本等于消失了。所以我把它从默认的 1.0 降到了 0.5,意思是每批只有一半图片做 mosaic,另一半保持原尺度。
5.2 训练过程的观察点和早停策略
训练不是启动了就万事大吉。我习惯在训练过程中盯三个东西:metrics/mAP_0.5、loss/box、以及验证集上每类的 AP。如果 mAP 还在稳定上升,说明模型还没学到位,不用急着停;如果验证集 mAP 已经连续 20 轮不涨了,果断早停,再训下去就是过拟合。
有个容易忽视的观察点是"训练损失下降但验证损失上升"。我第一次碰到这个情况时以为是超参数问题,调了半天学习率,后来发现是数据里有脏标签——一张图的标注框和实际内容对不上,模型在训练集上学到的"正确"模式跟验证集不一致。排查方式是把验证集里预测错误的图逐一输出来看,发现有几张图标注框确实标错了位置,修正之后验证损失立刻回归正常。
最终模型的结果:各类别综合 mAP@0.5 达到 0.758,其中脏污和玻璃破碎的 AP 超过 0.85,裂纹 AP 最低只有 0.61。这个结果符合预期,裂纹在可见光下确实最难分辨,很多只有一两个像素宽。
5.3 部署前的模型轻量化处理
训练得到的是best.pt,直接用来做 Django 接口时推理速度偏慢,因为 PyTorch 的动态图机制有额外开销。我做了两步优化:
第一步是导出为 TorchScript 格式:
model.export(format="torchscript")TorchScript 去掉了动态图的部分灵活性,换来的推理速度提升在 CPU 上很明显。实测在普通服务器 CPU 上,PyTorch 原版推理一张 640×640 图需要大约 350ms,TorchScript 版只需要 180ms,几乎快了一倍。
第二步是导出为 ONNX,配合 OnnxRuntime 推理。这个格式在 CPU 上的速度通常比 TorchScript 更快,而且不依赖 PyTorch 环境,部署更干净。但 ONNX 导出偶尔会遇到算子兼容问题,碰到这种情况检查opset版本,一般 11-13 之间都能顺利支持。
在 Django 项目中,我最终实际使用的是 ONNX 格式 + OnnxRuntime 的组合。
6. Django 层:把模型变成可用的 Web 检测服务
模型训练好只是完成了 30% 的工作,剩下 70% 是把模型封装进 Web 系统。Django 在这里扮演的角色是"壳",负责接收上传图片、调用模型推理、展示结果、存储记录。模型和 Web 层怎么解耦,是架构设计的关键。
6.1 项目结构和模块划分
整个 Django 项目我按下面的结构组织:
solar_detection/ ├── manage.py ├── detection/ # 核心应用 │ ├── views.py # 视图:接收请求、返回响应 │ ├── model_loader.py # 模型单例加载模块 │ ├── inference.py # 推理封装:图片预处理、后处理 │ ├── urls.py │ └── models.py # 数据记录模型 ├── uploads/ # 上传图片存储目录 ├── results/ # 结果可视化图片存储 ├── templates/ │ └── detection/ │ ├── index.html # 上传页面 │ └── result.html # 结果展示页面 └── solar_project/ ├── settings.py └── urls.pymodel_loader.py专门解决一个实际痛点:不要在每次请求时都重新加载模型。YOLO 模型文件几十 MB,加载一次需要一两秒,如果每次上传图片都加载,用户会等到崩溃。所以用单例模式保证模型只加载一次,进程生命周期内复用。
import onnxruntime as ort from functools import lru_cache @lru_cache(maxsize=1) def get_session(): so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL session = ort.InferenceSession("weights/best.onnx", sess_options=so, providers=["CPUExecutionProvider"]) return session6.2 推理接口的预处理与后处理逻辑
ONNX 模型接收的输入是形状为(1, 3, 640, 640)的 RGB 浮点数组,输出是一堆原始检测信息,包含坐标、置信度和类别概率。从原始图片到最终展示,中间有两个环节经常写错。
预处理环节,要先用cv2.cvtColor把 BGR 转成 RGB,然后resize到 640×640,注意这里用的插值方式要跟训练时保持一致。YOLO 训练用的letterbox是等比例缩放加灰边填充,不是直接拉伸。如果直接拉伸,长宽比变了,框的位置就会偏。我一开始偷懒直接用resize,结果检测框普遍偏移,特别是靠近图片边缘的区域,后来改成letterbox才恢复正常。
def letterbox(img, new_shape=640, color=(114, 114, 114)): shape = img.shape[:2] r = min(new_shape / shape[0], new_shape / shape[1]) new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = (new_shape - new_unpad[0]) / 2 dh = (new_shape - new_unpad[1]) / 2 img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img, r, (dw, dh)后处理环节,ONNX 输出的原始坐标是相对于 640×640 输入图的,需要先用letterbox记录的缩放因子r和填充偏移(dw, dh)还原到原图坐标。这一步的公式是:
x_orig = (x_input - dw) / r y_orig = (y_input - dh) / r写还原代码时最好写个单元测试,用一张已知框位置的图验证坐标还原是否精确到像素级,别等接到前端才发现框和缺陷位置对不上。
6.3 上传、检测、结果展示的完整交互流程
Django 这边页面逻辑不复杂。核心视图函数长这样:
from django.shortcuts import render from django.conf import settings from .model_loader import get_session from .inference import run_inference import os, uuid import cv2 def upload_and_detect(request): if request.method == "POST" and request.FILES.get("image"): img_file = request.FILES["image"] # 限制文件大小,防止超大图拖垮进程 if img_file.size > 20 * 1024 * 1024: return render(request, "detection/index.html", {"error": "图片不能超过20MB"}) ext = os.path.splitext(img_file.name)[1].lower() if ext not in ('.jpg', '.jpeg', '.png', '.bmp'): return render(request, "detection/index.html", {"error": "不支持的图片格式"}) # 保存文件 filename = f"{uuid.uuid4().hex}{ext}" upload_path = os.path.join(settings.MEDIA_ROOT, "uploads", filename) os.makedirs(os.path.dirname(upload_path), exist_ok=True) with open(upload_path, "wb+") as f: for chunk in img_file.chunks(): f.write(chunk) # 调用推理 session = get_session() boxes, scores, class_ids, result_img = run_inference(session, upload_path) # 保存结果图 result_name = f"result_{filename}" result_path = os.path.join(settings.MEDIA_ROOT, "results", result_name) cv2.imwrite(result_path, result_img) # 保存检测记录到数据库 record = DetectionRecord.objects.create( original_image=os.path.join("uploads", filename), result_image=os.path.join("results", result_name), detected_count=len(boxes), detail_json=format_detections(boxes, scores, class_ids) ) return render(request, "detection/result.html", { "record": record, "detections": format_detections(boxes, scores, class_ids) }) return render(request, "detection/index.html")这里值得注意的是文件类型校验和大小限制。曾经遇到过一次,用户上传了一个超大的无人机巡检原图,2000 万像素,预处理阶段直接占掉 2GB 内存,导致整个服务卡死。限制在 20MB 之后,加上后端统一压缩到 1280 宽再推理,体验和稳定性都有保障。
7. 部署上线:踩过的坑和验证过的方案
很多人做到 Django 页面能本地跑就交差了,但真要部署到服务器上让别人能用,还有一堆预料之外的问题。我这里分享几个真实踩过的坑和最终验证可行的方案。
7.1 开发服务器与生产环境部署的差别
Django 自带的开发服务器是单进程、单线程的,本地调试没问题,但一旦多人同时访问,一个慢请求就能把整个服务卡住。生产环境必须换成 WSGI 服务器,我用的是 Gunicorn。启动命令如下:
gunicorn solar_project.wsgi:application -w 3 -b 0.0.0.0:8000 --timeout 120-w 3表示 3 个 worker 进程,每个 worker 是独立进程,各自加载一份模型。虽然内存开销大了一些,但好处是一个请求卡住不会影响其他 worker 里的请求。--timeout 120是因为模型推理有时较慢,尤其是 CPU 环境下,默认 30 秒的超时很可能不够用,被 Gunicorn 强杀进程造成 504 就麻烦了。
这里有个资源规划问题:每个 worker 加载 ONNX 模型大约占 500MB 内存,3 个 worker 就是要 1.5GB。部署前先确认服务器内存够不够,不然开了 3 个 worker 直接内存溢出,连系统都可能崩。
7.2 并发请求时的模型推理安全
模型推理本身是线程不安全的,尤其是 ONNX Runtime 或者 PyTorch 在多线程下同时调用同一个会话,轻则报错,重则结果错误。Gunicorn 默认的 worker 类型是同步阻塞的,即同一个 worker 同时只能处理一个请求,这反而保证了推理资源不会被并发争抢。
如果你未来想把同步 worker 换成异步 worker 提升并发量,那推理部分必须加锁或改用每个线程单独创建会话,否则文件路径写同一个临时目录也会出问题。这属于典型的高速路堵车问题——看起来是推理慢,实际上是架构上没做好资源隔离。
7.3 Nginx 静态文件与反向代理配置
Django 不擅长处理静态文件和大文件传输,前面挂一层 Nginx 几乎是必须的。Nginx 做两件事:一是把/static/和/media/路径直接交给磁盘文件响应,不经过 Django;二是把其他请求反向代理给 Gunicorn。
nginx.conf核心配置示例:
server { listen 80; server_name your_server_ip; client_max_body_size 30m; location /static/ { alias /path/to/solar_detection/static/; } location /media/ { alias /path/to/solar_detection/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 120s; } }client_max_body_size 30m要重点留意。有一次部署后用户上传几 MB 的图片直接被 Nginx 拒了,报 413 错误,就是忘了配这个参数,Nginx 默认只允许 1MB 上传。
8. 可视化与系统功能补全:从"能检测"到"好用"
模型能检测、页面能展示,这时候系统已经具备雏形了,但离"好用"还差几个功能。实际使用过程中,我补全了下面这几块。
8.1 检测结果可视化的画框方案
YOLO 原始推理结果只是一组坐标和置信度,画框这步可以交给后端 OpenCV 做,也可以丢给前端 Canvas 做。我采用的是后端画框方案:推理完之后直接生成一张带框标注的图片存到results/目录,前端只负责展示图片和文字信息。
后端画框有个细节:不同类别要用不同颜色和标签前缀,方便用户一眼区分。我用的颜色方案:
| 类别 | 框颜色 | 标签 |
|---|---|---|
| 热斑 | 红色 | hotspot 0.87 |
| 裂纹 | 橙色 | crack 0.72 |
| 脏污 | 黄色 | dirt 0.93 |
| 玻璃破碎 | 蓝色 | broken 0.95 |
| 变形 | 紫色 | deformed 0.88 |
画框时置信度数值保留两位小数展示,小于 0.35 的框直接过滤不画,减少干扰项。
8.2 检测记录管理与历史查询
每次检测都写入数据库,这个功能一开始我嫌麻烦没做,后来运维同学说要追溯一块板子在不同日期的检测结果对比,才意识到这个功能必不可少。
记录表设计很简单:
class DetectionRecord(models.Model): original_image = models.CharField(max_length=255) result_image = models.CharField(max_length=255) detected_count = models.IntegerField(default=0) detail_json = models.TextField() # 存储所有检测框的详细信息 created_at = models.DateTimeField(auto_now_add=True)管理历史记录用 Django 自带的 Admin 就行,零代码获得筛选、排序、分页能力。如果需求更复杂,比如按时间范围统计缺陷类型分布,再单独写几个视图函数输出 JSON,前端用简单表格展示。
8.3 批量检测:巡检图片一键处理
单张上传适合演示,但真实巡检一次就是几百上千张图,一张一张上传能急死人。所以我在系统里加了一个批量检测功能:用户选择文件夹里的多张图片(ZIP 上传也行),后端排队依次检测,结果统一展示成列表,每张图点开能看到详细画框结果。
批量检测的核心是异步任务。直接用 Django 同步方式处理几十张图,用户请求会被阻塞好几分钟,体验很差。我用的方案是给 Gunicorn 增加一个专门的批量任务 route,由独立 worker 处理长任务,前端轮询任务状态。更轻量级的方案是直接用 Python 内置的threading开一个后台线程队列,对中小规模并发足够用。
批量检测的效果很直观。运维手里一个航拍架次大概 300 张图,原来人工看图需要大半天,现在丢进系统几分钟出结果,还能按照缺陷类型和置信度排序,优先处理置信度高的疑似缺陷。这个工作流上的改进,才是这类系统真正的落地价值。
9. 总结之外:调参心得、扩展方向与最后的建议
写到这,整套系统的完整链路基本讲完了。有一些细节没法在正文中展开,但恰恰是这些细节决定了系统的最终可用度,我放在这里作为补充经验分享。
数据比模型重要,这句话在缺陷检测场景里体现得淋漓尽致。同样的 YOLOv8 结构,我在数据清洗前后各训了一版,清洗后 mAP 提高了 6.3 个百分点。这 6.3 分完全来自标注质量的提升和数据分布的修正,跟模型结构没有半点关系。所以如果你的检测效果不理想,先怀疑数据,再怀疑模型,顺序不要搞反。
训练和部署环境的差异要注意。训练时用的 GPU、Python 版本、OpenCV 版本,和部署环境尽量保持一致。有次部署服务器上的 OpenCV 是 4.1 版本,训练环境是 4.8,同一个letterbox函数因为cv2.copyMakeBorder的边界处理差异,导致框偏移了几个像素。虽然不大,但在裂纹检测这种对精度要求高的任务里,几个像素的偏移就可能让一个框失效。
关于扩展方向,我认为这套系统最自然的延伸是接入视频流做实时巡检。目前单张图片检测已经有了,只需要把输入源从文件换成摄像头或无人机图传流,Django 侧加一条 WebSocket 通道,就能实现实时预警。另外一个方向是缺陷分级——现在输出的只是"有没有缺陷、什么缺陷",如果能进一步评估严重程度(轻微、中等、严重),对运维决策的辅助价值会大很多。这个可以在现有检测框的基础上,加一个分类头来实现。
最后再分享一个小技巧。用 Django 做推理服务时,建议把模型推理和 Web 请求解耦——模型推理封装成独立的服务模块,用消息队列或者简单文件轮询和前端交互。这样做的好处是,将来换模型、换框架,甚至把推理部分迁移到 GPU 服务器,Web 层完全不用改。我在实际项目中就经历过一次把 ONNX 换成 TenserRT 的升级,因为推理层解耦得当,Django 层一行代码没动,模型文件一换就完成升级,整个过程不到半天。架构上省下的时间,往往比调模型参数省下的时间多得多。
这套系统做完,我的体会是:深度学习项目的价值不在于模型多新多强,而在于能不能真正进入业务流程,解决真实问题。YOLOv8 不算最新,Django 更是元老级框架,但两者配合在一起,实实在在地把"检测一块太阳能电池板有没有缺陷"这件事自动化了,这就是它最大的意义。