☰
YOLOv8光伏电池缺陷检测实战:从数据标注到边缘部署
2026/9/26 8:57:48 网站建设 项目流程

简介:面向光伏电池质检场景的YOLOv8目标检测项目,适合工业视觉、缺陷检测方向的工程师与学习者上手实践。包内共1111个文件,压缩后约31.97MB,以Python脚本(如train.py、predict.py)、YAML/yml配置、TXT说明、Markdown文档和PNG图像为主,并包含模型权重pt、C++推理示例及Dockerfile,覆盖训练、验证、推理、部署等环节。已有557人学习下载。通过阅读文档和运行脚本,可掌握光伏电池划痕、污渍、裂纹等缺陷的数据组织方式与检测流程,理解YOLOv8的模型配置、训练参数和预测输出,并借助容器化配置在不同环境中快速复现。资源将算法原理、代码工程与工业场景结合,便于进一步扩展和调优。

1. 光伏电池缺陷检测为什么绕不开 YOLOv8

一条光伏产线每天产出几万片电池片,人工质检员盯着 EL 电致发光图像找隐裂、断栅、黑斑,看久了眼睛发花,漏检率随疲劳直线上升。这类场景有一个共同特点:缺陷目标小、对比度低、背景纹理强,而且缺陷类别之间形态差异极大——隐裂是一道细线,断栅是一段缺失,黑斑是一片晕染。早期用传统 OpenCV 阈值分割做,光照一变就翻车;用 Faster R-CNN 又太重,产线上几十台工控机跑不动。YOLOv8 把速度、精度和部署难度平衡到了工业可接受的范围:单张 640×640 推理在 GTX 1660 Ti 上能跑到几十毫秒,导出 ONNX 后还能塞进 RK3588 这类边缘盒子。这篇文章不聊论文复现,只讲怎么用 YOLOv8 把一个光伏电池缺陷检测项目从数据集一路做到能产线试运行,包括参数怎么设、曲线怎么看、以及哪些坑值得提前绕开。

2. 从光伏图像到可训练数据集:标注、切片与格式转换

2.1 EL 与可见光成像的差异,标注策略先定

光伏电池缺陷检测的输入图像主要有两种。可见光图像拍的是电池片表面,能看清划痕、脏污、崩边,但隐裂和断栅在可见光下几乎看不见;EL 电致发光图像让电池片通电发光,缺陷区域会表现为发光强度异常——隐裂是暗线,断栅是暗区,黑斑是局部暗块。实际项目里 EL 图是主流,因为隐裂直接影响到组件功率和热斑风险,是必须拦下的缺陷。

拿到 EL 图先别急着标。EL 图通常是一整片电池或整块组件的图像,分辨率可能到 2000×2000 以上,而缺陷宽度只有几个像素。直接把大图缩到 640×640 喂给 YOLOv8,隐裂会缩成一条模糊的线,模型根本学不到特征。所以第一步就是切片:把大图切成 640×640 或 512×512 的 patch,相邻 patch 之间留 50~100 像素重叠,避免缺陷恰好卡在切片边界被切断。切完的 patch 才是真正参与训练的样本。

标注工具常见的是 Labelme 或 X-AnyLabeling。YOLOv8 官方推荐的是矩形框检测,但隐裂这种长条目标用矩形框会框进大量背景,导致正样本中背景占比过高。我一般这么处理:隐裂和断栅用旋转框标,或者退一步用多个小矩形分段标;黑斑、崩边这类近圆形缺陷直接用普通矩形框。如果项目只允许用 axis-aligned 框,那宁可把隐裂拆成几段标,也不要一个框把整条缝包进去。训练目标不是让框完整包住缺陷,而是让模型学到缺陷的局部纹理特征,框只要贴合度高,loss 就容易收敛。

2.2 切片脚本:保留原图坐标与缺陷框坐标

切片这一步直接决定后续数据集质量。常见做法是写一个 Python 脚本,对每张大图做滑窗切片,同时把已经标注好的缺陷框也一起切出来。这里有个关键点:如果你先用 Labelme 在大图上标注,再切片,那么标注坐标是相对于大图的;切片时需要把大图坐标换算成 patch 内坐标,并过滤掉完全不在 patch 内的框。

下面是我在项目里常用的切片脚本骨架,输入是 Labelme 的 JSON 标注文件:

import json import cv2 import numpy as np import os def slice_image_with_labels(json_path, img_dir, out_dir, patch_size=640, overlap=80): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_path = os.path.join(img_dir, data['imagePath']) img = cv2.imread(img_path) h, w = img.shape[:2] shapes = data['shapes'] # 统一成矩形框: [x1, y1, x2, y2],原图坐标 boxes = [] for s in shapes: pts = s['points'] xs = [p[0] for p in pts] ys = [p[1] for p in pts] boxes.append([min(xs), min(ys), max(xs), max(ys), s['label']]) stride = patch_size - overlap patch_id = 0 for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): # 最后一行/列补齐 y_end = min(y + patch_size, h) x_end = min(x + patch_size, w) y = max(0, y_end - patch_size) x = max(0, x_end - patch_size) patch = img[y:y+patch_size, x:x+patch_size] patch_boxes = [] for (x1, y1, x2, y2, label) in boxes: nx1, ny1 = max(x1, x) - x, max(y1, y) - y nx2, ny2 = min(x2, x + patch_size) - x, min(y2, y + patch_size) - y if nx2 <= nx1 or ny2 <= ny1: continue # 框不在patch内 # 只保留有效面积占比足够的框,避免残缺框干扰 area_orig = (x2 - x1) * (y2 - y1) area_cut = (nx2 - nx1) * (ny2 - ny1) if area_cut / area_orig < 0.3: continue patch_boxes.append([nx1, ny1, nx2, ny2, label]) if not patch_boxes: continue # 没有缺陷的纯背景patch,按需保留或丢弃 out_img = os.path.join(out_dir, f'{patch_id:06d}.jpg') out_txt = os.path.join(out_dir, f'{patch_id:06d}.txt') cv2.imwrite(out_img, patch) with open(out_txt, 'w') as f: for (nx1, ny1, nx2, ny2, label) in patch_boxes: # 转YOLO格式:归一化的中心坐标和宽高 x_center = (nx1 + nx2) / 2 / patch_size y_center = (ny1 + ny2) / 2 / patch_size bw = (nx2 - nx1) / patch_size bh = (ny2 - ny1) / patch_size f.write(f'{0} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}\n') patch_id += 1 print(f'Done: {patch_id} patches from {img_path}')

这个脚本的要点是坐标换算和面积过滤。坐标换算容易出错的地方是切片边界处的y_end处理:当大图高度不能被步长整除时,最后一行切片会超出边界,我用y_end = min(y + patch_size, h)再回退修正y,保证每个 patch 尺寸严格等于 patch_size。面积过滤条件area_cut / area_orig < 0.3会把被切掉超过七成的残缺框丢掉,这些残缺框往往只有几个像素,留着只会引入噪声标注。

参数上,patch_size=640对应 YOLOv8 默认输入分辨率,省去训练时再放缩的精度损失;overlap=80是经验值,覆盖了大部分隐裂的连续长度,又不至于让相邻 patch 重复内容太多。如果缺陷更小,比如细栅线缺失宽度只有 3 像素,建议把 patch_size 降到 512,overlap 提至 100,让缺陷在 patch 内占比更大。

提示:纯背景 patch 我通常会丢弃,只保留含缺陷的 patch,再按一定比例掺入背景 patch 防止误检。比例控制在 3:1 到 5:1 之间,背景太多模型会偏向把所有区域都判为背景。

2.3 转成 YOLO 格式:标注工具与目录组织

Labelme 的 JSON 不能直接喂给 YOLOv8,需要转成 YOLO 的 TXT 格式。YOLO 格式每一行是类别id x_center y_center width height,坐标是归一化到 0~1 的小数。上面切片脚本里我直接做了转换,如果你是在大图上标注后整体缩放训练,就需要单独的转换脚本。这里给出一个处理 Labelme 多边形框的转换函数:

import json import os def labelme_to_yolo(json_path, out_txt, class_map): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_w, img_h = data['imageWidth'], data['imageHeight'] with open(out_txt, 'w') as out: for shape in data['shapes']: label = shape['label'] if label not in class_map: continue pts = shape['points'] xs = [p[0] for p in pts] ys = [p[1] for p in pts] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) bw = (x_max - x_min) / img_w bh = (y_max - y_min) / img_h x_center = ((x_min + x_max) / 2) / img_w y_center = ((y_min + y_max) / 2) / img_h out.write(f'{class_map[label]} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}\n')

转换后数据集目录要按 YOLOv8 的约定组织:

dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/

每个图像文件名和对应 TXT 必须同名,TXT 中每一行对应一个目标框。一个常见坑:Labelme 标注里有些框坐标超出图像边界(手抖多画了一点),转出来的宽高可能超过 1.0。YOLOv8 训练时遇到超界坐标会警告但不崩溃,但会拉低精度。建议在转换脚本里 clamp 一下:x_center = min(max(x_center, 0.0), 1.0),width = min(width, 1.0)。

类别映射我建议用字典而不是硬编码数字。光伏电池缺陷检测常见类别就 5~7 类:隐裂(crack)、断栅(finger interruption)、黑斑(dark spot)、崩边(edge break)、划痕(scratch)、组件中的破片(broken cell)。不要一开始就铺 10 个类别,把形态相近的先合并,比如把黑斑和暗片归为一类,因为它们的纹理特征在 EL 图里高度重叠,强行分开会让模型在边界处摇摆。

3. 在 Ubuntu 上搭建 YOLOv8 环境:从 CPU 到 GPU 的最小命令集

3.1 Python 虚拟环境与 ultralytics 安装

Ubuntu 20.04 是工业视觉项目里最常见的系统版本。搭建 YOLOv8 环境前先确认 Python 版本,建议 3.8~3.11,实测 3.10 最稳。用 conda 或 venv 建一个独立环境,别直接往系统 Python 里装——YOLOv8 依赖 torch、opencv、numpy,版本一冲突整个系统环境就瘫了。

# 安装虚拟环境(如果还没装conda,用python3-venv也行) python3 -m venv yolov8_env source yolov8_env/bin/activate # 先装CPU版torch,如果你打算用GPU,跳到下面的CUDA版本 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 装ultralytics,会自动带上opencv-python、numpy等依赖 pip install ultralytics # 验证安装 python -c "from ultralytics import YOLO; print(YOLO.__name__)"

如果你是 NVIDIA 显卡,用 GPU 版 torch 安装方式略有不同。先查驱动和 CUDA 版本,再选对应的 torch。一般我会装 CUDA 11.8 对应的 torch 1.13 或 2.x:

# 查看显卡驱动支持的最高CUDA版本 nvidia-smi # 安装对应torch,假设CUDA=11.8 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics

CPU 版和 GPU 版的唯一区别就在 torch 的安装源。装完 ultralytics 后,YOLOv8 的命令行入口是yolo,也可以用 Python API。环境搭建阶段最容易翻车的是 opencv 和 numpy 版本冲突:ultralytics 要求 numpy>=1.23,如果你机器上之前装过 numpy 1.19,会出现导入报错_ARRAY_API not found。解决办法是pip install -U numpy。另外 opencv 如果装的是 opencv-python-headless,某些可视化功能会缺失,建议在桌面环境装完整版opencv-python。

# opencv版本检查,确认cv2能正常导入 python -c "import cv2; print(cv2.__version__)"

3.2 数据集 YAML 与目录组织

YOLOv8 训练不直接读文件目录,而是通过一个 YAML 文件告诉它数据在哪、类别是什么。YAML 格式很简单:

# dataset.yaml path: /home/user/dataset # 数据集根目录 train: images/train # 训练图像目录(相对于path) val: images/val # 验证图像目录 test: images/test # 可选 names: 0: crack 1: finger_interruption 2: dark_spot 3: edge_break

这里有两个隐藏要求:path下的images和labels目录必须在同一级,且图片目录下的文件名要和标签目录下的 TXT 文件名一一对应。很多新手把标签放在labels/train但图片放在train/images,路径一错就报No labels found。YOLOv8 启动训练时会先扫描数据集,如果某个图片没有对应 TXT,它会认为这张图里没有目标,这没问题;但如果 TXT 里的类别 id 超过了names中定义的数量,训练会直接报错。另一个常见报错是Dataset 'dataset.yaml' not found,检查 YAML 文件路径是否写成了相对路径,建议用绝对路径。

3.3 训练参数怎么设:epochs、imgsz、batch、lr 的合理起步值

训练参数直接决定模型能不能收敛。YOLOv8 默认参数在 COCO 上表现不错,但光伏电池缺陷检测的数据集规模小、目标尺寸分布特殊,必须手动调整。

yolo detect train \ model=yolov8n.pt \ data=dataset.yaml \ epochs=200 \ imgsz=640 \ batch=16 \ lr0=0.01 \ lrf=0.01 \ optimizer=AdamW \ patience=20 \ project=runs \ name=cell_defect

参数含义逐个说:

  • model=yolov8n.pt用 nano 预训练权重做迁移学习起始点。光伏电池图像和 COCO 自然图像差距较大,但骨干网络提取底层纹理特征的能力可以复用,收敛更快。n 模型最小,显存占用低;如果你有 12GB 以上显存,可以换成yolov8s.pt,精度会高一些。
  • epochs=200是起步值。光伏数据集如果只有几千张 patch,200 轮足够;但如果类别多且样本不均衡,可能要 300。配合patience=20做早停,验证集 mAP 连续 20 轮不提升就自动停止,省时间。
  • imgsz=640必须和切片尺寸一致。如果你切片是 512,这里也写 512,避免训练时再缩放到 640 造成信息丢失。
  • batch=16取决于显存。GTX 1660 Ti 6GB 只能勉强跑batch=8,显存不足就调小,不要硬撑,否则出现 CUDA OOM。
  • lr0=0.01是初始学习率。YOLOv8 默认就是 0.01,配合 AdamW 在中小数据集上表现稳定。如果你发现 loss 前几轮震荡剧烈,降到 0.001。
  • optimizer=AdamW比 SGD 在工业小数据集上收敛更稳。YOLOv8 默认是 SGD,但在缺陷检测这种低对比度任务上 AdamW 往往 mAP 更高。

训练过程会实时打印每轮的 box_loss、cls_loss、dfl_loss 和验证集的 precision、recall、mAP50、mAP50-95。看到 mAP50 在 0.9 以上、mAP50-95 在 0.6 以上就具备试运行条件了。损失曲线图保存在runs/detect/name/results.png,后面我会讲怎么看。

注意:yolo命令和YOLO("yolov8n.pt")Python API 是两套入口,参数书写格式略有差异。命令行用yolo detect train,Python 里用model.train(data=..., epochs=...),别混用。

4. 训练缺陷检测模型:损失曲线判读与关键参数调优

4.1 启动训练,从 results.png 判断模型是欠拟合还是欠训练

训练跑起来后,新手最常问的一句话是「这 loss 降到多少算好」。我的判断顺序是:先看训练集 loss 是否持续下降,再看验证集 mAP 是否同步上升,最后看 precise vs recall 的平衡。

YOLOv8 的results.png里有 box_loss、cls_loss、dfl_loss 三条曲线,横轴是 epoch。正常情况是三条 loss 在 20 轮前快速下降,之后缓慢走平。如果 loss 已经走平但 mAP50 还不到 0.5,说明模型容量不够或数据有问题,不是训练轮次不够。这里有个反直觉的地方:光伏电池缺陷检测里,训练集 loss 降到 0.01 以下不一定好,往往是模型把某些噪声当成了特征,即过拟合。判断过拟合看验证集 loss 是否在某个 epoch 后回升,如果回升了,说明模型开始背诵训练集。

我常用的一个技巧是看前 10 轮的 loss 下降斜率。如果前 10 轮 box_loss 从 2.0 降不到 1.0,大概率是学习率设置的太大或 batch 太小导致梯度不稳定。把 lr0 降到 0.005,或把 batch 提到 32(如果显存允许),一般能解决。

另一个经常被忽略的参数是close_mosaic。YOLOv8 默认最后 10 轮会关闭 mosaic 数据增强,以便模型在真实分布上微调。但工业缺陷检测中 mosaic 会把不同电池片的 EL 图像拼在一起,缺陷上下文被破坏,某些项目比如隐裂检测,建议直接用mosaic=0.0关闭,改用scale=0.3和fliplr=0.5这类轻微的几何增强。缺陷检测不需要 mosaic 这种强增强,强增强反而让模型学到拼接缝的伪特征。

4.2 类别不均衡与难样本:给易错类别单独调权重

光伏电池缺陷检测数据集几乎必然类别不均衡。隐裂可能占了训练样本的 60%,断栅占 25%,黑斑占 10%,崩边只有 5%。YOLOv8 原生没有 per-class loss 权重参数,但可以通过两种方式缓解。第一种是类别采样,让每个 epoch 读取的不同类别 patch 数量尽量均衡——我写过一个简单的按类别比例采样器,训练时先按类别统计,每个 patch 若含有稀有缺陷类别就提高采样权重。第二种是后处理:训练完成后单独看每个类别的混淆矩阵,找出 recall 最低的类别,在val阶段针对它调低置信度阈值,或者增加该类别的人工标注。

我在项目里常做的是用model.val(conf=0.1)来看低置信度下的表现。如果崩边类在 conf=0.25 下 recall 只有 0.4,但 conf=0.1 时能到 0.8,说明模型其实学到了特征,只是置信度打分保守。这时候与其调模型,不如在部署阶段对崩边类单独用conf=0.1,其他类保持conf=0.25。YOLOv8 的 Python API 支持 per-class conf 吗?官方不直接支持,但可以后处理:对输出的预测框按类别重映射置信度阈值。

from ultralytics import YOLO import numpy as np model = YOLO("runs/detect/cell_defect/weights/best.pt") results = model("test_image.png", conf=0.25, verbose=False) # per-class conf调整:把crack类的置信度阈值单独降到0.15 class_conf_thr = {0: 0.15, 1: 0.25, 2: 0.25, 3: 0.2} for r in results: boxes = r.boxes if boxes is None: continue cls = boxes.cls.cpu().numpy().astype(int) conf = boxes.conf.cpu().numpy() xyxy = boxes.xyxy.cpu().numpy() keep = [] for i in range(len(cls)): if conf[i] >= class_conf_thr.get(cls[i], 0.25): keep.append(i) keep = np.array(keep, dtype=int) # 此时filtered_boxes即为按类别阈值过滤后的结果 filtered_conf = conf[keep]

这段代码在做的事:先用一个保守的高阈值跑推理,骗过置信度低的框,再按类别动态过滤。注意model()内部已经做了一次 NMS,如果你把 conf 调得过低,会出来大量假阳性框,所以要先跑一轮拿到结果再过滤,而不是直接调低 conf 参数。

4.3 模型导出与部署:ONNX 与 RK3588 的转换注意事项

训练完best.pt,产线部署一般不会直接跑 PyTorch。常见做法是导出 ONNX,再转成 RKNN(瑞芯微 RK3588 平台)或 TensorRT(NVIDIA 平台)。YOLOv8 导出 ONNX 只需一条命令:

yolo export model=runs/detect/cell_defect/weights/best.pt format=onnx imgsz=640 opset=12

导出后有两点必须检查。第一,用 onnxruntime 验证输入输出形状,输入是[1,3,640,640],输出可能是 1 个或 3 个 tensor,取决于是否开启end2end。第二,NMS 是否包含在模型内。ONNX 默认不包含 NMS,输出的是原始预测框,需要在部署代码里用cv2.dnn.NMSBoxes或 ONNX Runtime 的NonMaxSuppression算子做后处理。RK3588 部署时,瑞芯微的 RKNN-Toolkit2 支持转换 ONNX,但有版本兼容要求,通常建议 YOLOv8 用opset=12,并且在导出时开启simplify=True减少算子类型,否则转 RKNN 时容易遇到不支持的算子。

yolo export model=best.pt format=onnx imgsz=640 opset=12 simplify=True

部署端输入分辨率必须和训练一致。很多项目在训练时用 640,部署时为了提速改成 512,结果 mAP 掉了 5 个点。因为模型在 640 下学到的 anchor 尺度固定了,输入变小后小目标的特征图位置发生偏移。如果你确定部署端算力受限,训练时就直接用 512,不要训练 640 部署 512。

5. 避坑:光伏电池缺陷检测项目里的 5 个常见翻车点

5.1 现象:隐裂漏检严重,尤其是细长裂缝

原因:切片 patch 尺寸大于缺陷本身,模型在特征图上的感受野覆盖了太多背景,导致隐裂的响应被平均掉。另一个原因是训练时开了 mosaic,隐裂被拼接边缘截断,模型学到的模式不完整。

解决:先把 patch 从 640 降到 512,overlap 从 80 提到 128,保证每条隐裂在至少一个 patch 中是完整连续的。其次,检查标注框是否过宽——如果矩形框把隐裂周围几毫米的正常电池也包进去,模型学到的特征是「一条宽暗带」而不是「细暗线」,推理时会把宽暗带误判为隐裂。建议把隐裂框缩到紧贴缺陷边缘,必要时用旋转框,YOLOv8 有 OBB 分支,但部署复杂度高,这里不展开。

5.2 现象:训练损失正常下降,但验证集 mAP50-95 卡在 0.3 以下

原因:最常见的是数据集中同一缺陷被重复切片,导致训练集和验证集有大量重叠区域。比如一张大图切成 20 个 patch,随机分到 train 和 val 后,val 里某个 patch 和 train 里相邻 patch 有 100 像素重叠,重叠区域参与了训练,val 测出来的指标虚高。反过来,如果切片时把同一张图的 patch 强行按图切分,不同图的光照差异极大,模型泛化能力弱,验证集 mAP 就真实地低。

解决:按「图」划分训练集和验证集,而不是按 patch。先完整统计有哪些大图,按 8:1:1 切分,然后对每张图做切片。我的脚本里会输出patch_id和source_image的映射,训练前把同一 source 的所有 patch 放在同一个集合里。验证集里不要出现来自训练图的 patch 内容。

5.3 现象:CPU 训练慢到怀疑人生,一个 epoch 半小时

原因:CPU 版 torch 默认不开启多线程,且 YOLOv8 数据加载的 num_workers 默认只有 8,在 CPU 上反而造成线程切换开销。很多人直接在 Ubuntu 20.04 CPU 环境pip install ultralytics后跑训练,一个 6000 张 patch 的数据集,一个 epoch 可能要 40 分钟。

解决:如果只有 CPU,至少把torch.set_num_threads(8)加上,并把workers参数降到 4 或 2。CPU 环境下batch不要超过 8,否则内存和 CPU cache 都会溢出。最根本的解法是租一台有 GPU 的云服务器或找一台 3060 以上的本地机器,哪怕是旧款 GTX 1660 Ti 6GB 也比 CPU 快 20 倍。如果是公司产线,不建议为了省成本在 CPU 上耗时间,光数据集迭代调参就够亏了。

# CPU训练时加上workers限制,避免卡死 yolo detect train model=yolov8n.pt data=dataset.yaml epochs=200 imgsz=640 batch=8 workers=2

5.4 现象:部署后对正常电池片频繁误检,尤其是边缘区域

原因:训练数据里如果只包含缺陷 patch 和有缺陷的电池图,模型没见过足够多的负样本——整片无缺陷的 EL 图。在推理时,正常电池片边缘由于电极栅格的黑色条纹,与隐裂的暗线特征非常相似,被误判为 crack。

解决:在训练集中加入 30%~50% 的无缺陷 patch。这些 patch 从正常电池片中切片,标注文件为空。YOLOv8 支持空标注图片,训练时不会报错。加入负样本后,模型的 precision 会显著提升。另外,推理时对边缘区域做后处理:如果检测框中心到图像边缘的距离小于 5 像素,且该框类别是 crack,单独提高置信度阈值到 0.4,因为边缘处的正常纹理最容易产生假阳性。

5.5 现象:导出 ONNX 后推理结果和 PyTorch 不一致

原因:两个层面。第一,YOLOv8 的best.pt训练时启用了 TTA(Test-Time Augmentation)的话,导出默认不带 TTA,结果自然有差异,但一般没人开 TTA。第二,ONNX 的算子实现精度问题,比如SiLU在某些 opset 版本下与 PyTorch 实现有微小误差,导致置信度偏差 0.01 以内,这不影响最终分类,但如果你在部署时用了严格的 NMS IoU 阈值 0.5,可能出现个别框被 NMS 合并的情况。更常见的是输入预处理不一致:训练时 YOLOv8 内部会对图像做 letterbox(缩放到 640×640 并填充灰边),而部署代码如果没有做同样的 letterbox,而是直接 resize 拉伸,目标形状被扭曲,检测框位置和置信度都会漂移。

解决:导出后先用 ONNX Runtime 跑一张验证图,对比 PyTorch 推理的前 100 个框的坐标差异。如果差异小于 1 像素,说明流程对了。如果差异大,检查预处理代码。YOLOv8 官方给的 letterbox 参数是(width, height)=(640,640),填充颜色(114,114,114),scale 按长边缩放。不要自己改。

6. 验证与进阶:批量预测统计缺陷密度,把模型压成产线工具

训练完模型后,最缺的不是更多训练,而是一个能直接回答「这批电池片到底好不好」的自动化脚本。我在项目里的做法是:用 Python API 批量跑测试图,把每个框的面积和类别汇总,按图统计缺陷密度(缺陷像素面积 / 电池面积),超过设定阈值的图标记为 NG,否则 OK。这样产线质检员不需要看一堆框,只看 OK/NG 标签和缺陷密度数值。

下面是一个批量预测脚本,输入是某批次电池片的 EL 图像目录,输出一个 CSV 报告。

from ultralytics import YOLO import cv2 import os import csv model = YOLO("runs/detect/cell_defect/weights/best.pt") img_dir = "batch_images/" csv_path = "defect_report.csv" area_thresholds = { 0: 50, # crack 缺陷像素面积阈值 1: 30, # finger_interruption 2: 100, # dark_spot 3: 80, # edge_break } def pixel_area(box, conf=0.25): x1, y1, x2, y2 = box return (x2 - x1) * (y2 - y1) with open(csv_path, 'w', newline='') as f: writer = csv.writer(f) writer.writerow(["image", "class", "count", "total_area", "verdict"]) for img_name in sorted(os.listdir(img_dir)): if not img_name.endswith(".jpg"): continue img_path = os.path.join(img_dir, img_name) results = model(img_path, conf=0.25, imgsz=640, verbose=False) summary = {} for r in results: boxes = r.boxes if boxes is None: continue cls = boxes.cls.cpu().numpy().astype(int) xyxy = boxes.xyxy.cpu().numpy() for c, box in zip(cls, xyxy): area = pixel_area(box) summary.setdefault(c, []).append(area) verdict = "OK" for c, areas in summary.items(): total_area = sum(areas) if total_area > area_thresholds.get(c, 999): verdict = "NG" writer.writerow([img_name, c, len(areas), total_area, verdict])

脚本里我们按类别给缺陷面积阈值设了不同标准:crack 只要累计面积超过 50 像素就判 NG,dark_spot 要超过 100 像素。实际项目中这些阈值要靠一批已判定 OK/NG 的历史图像回测确定。我的经验是:先统计所有 OK 样本的缺陷面积分布,取 95 分位数作为阈值初值,再人工过一遍边界案例微调。

进阶一点,可以把这个脚本包装成一个 HTTP 服务,产线相机拍照后直接 POST 图片,返回 JSON 结果。或者用多线程并行跑,把model.predict的batch=8参数打开,一次处理 8 张图。YOLOv8 的predict接口支持传入图像路径列表,批量推理时自动按 batch 分组,这样一张 640×640 的推理时间在 GPU 上可以压到 10 毫秒以内。

一个值得养的长期习惯是:每次调完参数后,把训练命令、数据集版本和该批次下最佳权重文件名的映射记录到项目的experiments.md里。做了三个版本之后你就会发现,真正耗时间的不是训练本身,而是复现上一次某个效果还不错的组合时找不到当时用了哪些参数。现在我在每个训练输出目录下都放一个command.txt,由启动脚本自动写入完整的训练命令与数据集 hash,三个月后回来看仍然知道发生了什么。

这个项目方向上,最值钱的资产往往不是那个 90% mAP 的权重,而是你沉淀出的标注规范、切片策略和阈值标准。希望这些经验对你有帮助。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询