河道垃圾检测:VOC数据集转换与YOLOv8训练全流程

发布时间:2026/9/10 14:14:07
河道垃圾检测:VOC数据集转换与YOLOv8训练全流程 简介VOC格式目标检测数据集专注于河道垃圾和水面漂浮物识别适合计算机视觉学习者、环保工程师及智慧水务项目开发者。压缩包约592MB内含1304张JPG图像、1304个XML标注文件及1个说明文本共2600余个文件。XML严格遵循PASCAL VOC规范每个文件记录图像文件名、宽高及目标对象的类别和边界框坐标边界框精确定位到像素级可直接用于Faster R-CNN、SSD等经典检测模型的训练与评估。图像覆盖不同光照和背景的真实河道场景有助于提升模型在复杂环境下的泛化能力。虽未提供YOLO格式txt及分割路径标注但开发者可基于现有XML自行转换为YOLO或TFRecord格式适配更多算法。目前已有1589人学习浏览适合作为河道垃圾自动检测、水面漂浮物监测系统的研究基础帮助快速搭建和验证目标检测算法。1. 河道垃圾检测为什么需要先解决数据集格式河道垃圾和水面漂浮物检测一直是环境监测领域的高频需求摄像头点位覆盖广、背景复杂、目标小且易受光照和水纹干扰模型能否落地往往不取决于网络结构而取决于训练数据的标注质量与格式匹配。这份“河道垃圾水面漂浮物数据集”提供了1304张真实场景图像并且以VOC格式保留了所有边界框标注这对于想用YOLO系列做工程化部署的人来说刚好处于“可用但没完全可用”的状态——VOC的XML标注需要先理解、转换、再喂给训练框架。本文会从XML字段解析开始一路讲到坐标换算、训练配置、验证评估和难例分析目标是让这份数据真正跑通一次完整的目标检测流程。2. VOC格式的XML标注结构解析与读取2.1 VOC标注到底记录了什么VOC格式源于PASCAL VOC挑战赛后来被大量检测框架沿用。和YOLO的纯文本标注不同VOC用XML文件描述每张图的所有目标信息。一个典型的标注文件会包含filename、size和多个object节点每个object内部有name、pose、truncated、difficult和bndbox。对于水面漂浮物场景bndbox是最关键的部分它表示目标物体的矩形框坐标为像素绝对值。下面是一个与本数据集结构一致的XML示例annotation folderfirc_rivertrash/folder filenamefirc_rivertrash_815.jpg/filename size width1920/width height1080/height depth3/depth /size object nameplastic_bottle/name difficult0/difficult bndbox xmin320/xmin ymin254/ymin xmax486/xmax ymax411/ymax /bndbox /object /annotation字段含义整理如下表节点类型说明filename字符串对应的jpg图片文件名width / height整数图像的像素宽度和高度name字符串目标类别名称如塑料瓶、泡沫盒difficult0或11表示目标难以辨认训练时常跳过bndbox / xmin,ymin,xmax,ymax整数左上角和右下角的像素坐标由于本数据集没有提供分割用的txt或掩码文件后缀全是jpg和xml说明它定位在目标检测任务而不是分割任务。2.2 用Python批量解析XML标注训练前先写一个解析脚本把1304个XML读出来检查类别、边界框数量和图片是否匹配。常用标准库xml.etree.ElementTree即可完成。import os import xml.etree.ElementTree as ET annotations_dir Annotations class_stats {} total_boxes 0 error_files [] for xml_file in os.listdir(annotations_dir): if not xml_file.endswith(.xml): continue xml_path os.path.join(annotations_dir, xml_file) try: tree ET.parse(xml_path) root tree.getroot() filename root.find(filename).text width int(root.find(size/width).text) height int(root.find(size/height).text) for obj in root.findall(object): name obj.find(name).text difficult int(obj.find(difficult).text) bndbox obj.find(bndbox) xmin int(bndbox.find(xmin).text) ymin int(bndbox.find(ymin).text) xmax int(bndbox.find(xmax).text) ymax int(bndbox.find(ymax).text) total_boxes 1 class_stats[name] class_stats.get(name, 0) 1 if xmin xmax or ymin ymax: error_files.append(xml_file) print(f非法边界框: {xml_file}, {name}) except Exception as e: error_files.append(xml_file) print(f解析失败: {xml_file} - {e}) print(类别统计:, class_stats) print(总目标框数:, total_boxes) print(异常文件数:, len(error_files))脚本逻辑很直接遍历Annotations目录每个XML文件解析出文件名、宽高和所有目标。size下再取width和heightobject节点循环时从bndbox提取四角坐标。如果出现xmin xmax这类情况说明标注有异常需要单独处理否则后续训练会直接崩掉。提示difficult字段在训练时应做策略处理。许多框架默认将其丢弃但对于河道垃圾来说困难样本往往包含被水波遮挡的物体直接丢掉会让模型在真实场景的召回率下降。建议转换时保留单独作为一个训练子集分析。2.3 标注一致性检查与实际场景排雷在做坐标转换之前还需要确认图片和XML的对应关系。常见坑包括JPEG图像实际宽高与XML中记录的size不一致、文件名前后缀有差异、某个XML没有任何object。这些都会在构建数据集时产生难排查的错误。我通常会做一个快速校验import os from PIL import Image xml_files {os.path.splitext(f)[0] for f in os.listdir(Annotations)} jpg_files {os.path.splitext(f)[0] for f in os.listdir(JPEGImages)} missing_xml jpg_files - xml_files missing_jpg xml_files - jpg_files print(f缺少XML的图片: {len(missing_xml)}) print(f缺少图片的XML: {len(missing_jpg)}) for base in list(missing_jpg)[:5]: xml_path fAnnotations/{base}.xml root ET.parse(xml_path).getroot() w int(root.find(size/width).text) h int(root.find(size/height).text) img_path fJPEGImages/{base}.jpg if os.path.exists(img_path): img_w, img_h Image.open(img_path).size if img_w ! w or img_h ! h: print(f尺寸不一致: {base} xml({w},{h}) img({img_w},{img_h}))这一步能提前过滤掉脏数据避免训练时突然报shape错误。接下来就可以进入真正的转换工作。3. 从VOC到YOLO标注坐标转换与数据集划分3.1 YOLO格式与VOC坐标系的差异YOLO系列的标注不是记录左上角和右下角的像素坐标而是使用归一化后的中心点坐标与框宽高。具体换算公式为x_center (xmin xmax) / 2 / width y_center (ymin ymax) / 2 / height box_width (xmax - xmin) / width box_height (ymax - ymin) / height所有值都在0到1之间这样无论图像被resize到多少尺寸标注都不会变形。VOC转YOLO的核心就是这套公式。注意有些早期数据集的图片高度和宽度在XML里写反了因此转换前一定以实际图像尺寸为准。3.2 转换脚本实现下面这段代码把VOC的Annotations文件夹转换成YOLOv5/YOLOv8可以直接读取的labels文件夹同时生成classes.txt。import os import xml.etree.ElementTree as ET from pathlib import Path annotations_dir Annotations images_dir JPEGImages target_images_dir images target_labels_dir labels classes_file classes.txt os.makedirs(target_images_dir, exist_okTrue) os.makedirs(target_labels_dir, exist_okTrue) class_name2id {} def voc2yolo(xml_path, image_width, image_height): root ET.parse(xml_path).getroot() lines [] for obj in root.findall(object): name obj.find(name).text difficult int(obj.find(difficult).text) if name not in class_name2id: class_name2id[name] len(class_name2id) 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 difficult: continue x_center (xmin xmax) / 2.0 / image_width y_center (ymin ymax) / 2.0 / image_height w (xmax - xmin) / image_width h (ymax - ymin) / image_height lines.append(f{class_name2id[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) return lines for xml_file in os.listdir(annotations_dir): if not xml_file.endswith(.xml): continue xml_path os.path.join(annotations_dir, xml_file) root ET.parse(xml_path).getroot() filename root.find(filename).text size root.find(size) width int(size.find(width).text) height int(size.find(height).text) lines voc2yolo(xml_path, width, height) base_name os.path.splitext(xml_file)[0] with open(os.path.join(target_labels_dir, base_name .txt), w) as f: f.write(\n.join(lines)) src os.path.join(images_dir, filename) dst os.path.join(target_images_dir, filename) if os.path.exists(src) and not os.path.exists(dst): os.symlink(src, dst) with open(classes_file, w) as f: for name, idx in sorted(class_name2id.items(), keylambda x: x[1]): f.write(name \n) print(fclasses: {class_name2id})转换步骤有几个关键点difficult为1的框在写入标签时被跳过这不是必选但能稳定早期训练。为了防止复制图片占用双倍空间这里用os.symlink创建符号链接读取时无感知。classes.txt的类顺序完全由首次出现的顺序决定后续训练配置必须与此一致。3.3 划分训练集、验证集和测试集划分数据时不能直接随机扔因为河道视频采集的照片在时间上相关相邻几帧可能高度相似。我一般按文件名前缀做分组或者直接按键值排序后按比例切分。import random from pathlib import Path image_paths list(Path(images).glob(*.jpg)) random.seed(42) random.shuffle(image_paths) train_ratio 0.8 val_ratio 0.15 train_paths image_paths[:int(len(image_paths) * train_ratio)] val_paths image_paths[int(len(image_paths) * train_ratio):int(len(image_paths) * (train_ratio val_ratio))] test_paths image_paths[int(len(image_paths) * (train_ratio val_ratio)):] def write_list(paths, list_file): with open(list_file, w) as f: for p in paths: f.write(str(p.resolve()) \n) write_list(train_paths, train.txt) write_list(val_paths, val.txt) write_list(test_paths, test.txt) print(ftrain: {len(train_paths)}, val: {len(val_paths)}, test: {len(test_paths)})生成的三份txt文件每行是图片绝对路径YOLO官方加载工具会读取同目录下的同名labels文件。需要注意的是如果图片存在符号链接resolve()会指向真实路径跨平台部署时会丢失链接关系建议换成普通拷贝。3.4 类别映射与类别不平衡1304张图里到底有几个类别需要看classes.txt生成后的结果。河道垃圾常见类别包括塑料瓶、泡沫、白色污染物、植物残体等但实际标注可能统一归类为trash。如果只有单一类别那class_name2id就只有一个映射训练时一切从简。若是多类别且分布极度不均比如“塑料瓶”有5000个框“口罩”只有20个就需要做重采样或者提升cls损失的权重。这里给出一个快速统计脚本供转换后验证from collections import Counter import os label_dir labels counter Counter() for label_file in os.listdir(label_dir): with open(os.path.join(label_dir, label_file)) as f: for line in f: class_id int(line.split()[0]) counter[class_id] 1 print(每个类别出现的框数:, counter.most_common())如果某个类别比例低于0.05我通常会在训练时用YOLOv8的dataloader参数设置mosaic0或者先做在线过采样避免模型只记住大类别。4. 基于YOLOv8训练自己的河道垃圾数据集4.1 准备YOLO格式的数据集配置YOLOv8训练时需要一个data.yaml它描述数据路径、类别数量和类别名称。创建一个river_trash.yamlpath: ./river_trash train: images/train val: images/val test: images/test names: 0: plastic_bottle 1: foam_board 2: white_pollutant目录建议按实际需求调整YOLOv8不强制要求train和val子目录分隔但如果仍保留3.2节的images文件夹可以手动把图片和标签分进train和val子目录。标签文件夹名必须与图片文件夹名对应例如图片在images/train下标签就必须在labels/train下。注意YOLOv8的path字段可以写相对路径也可以写绝对路径。在容器中训练时绝对路径反而容易出问题建议统一使用相对路径并保持项目结构一致。4.2 训练命令与关键参数训练可以直接通过ultralytics包命令行完成yolo detect train \ datariver_trash.yaml \ modelyolov8n.pt \ epochs200 \ imgsz640 \ batch16 \ lr00.01 \ augmentTrue \ patience30 \ device0,1解释一下关键参数imgsz640把输入图像统一缩放为640×640。如果原始图像是1920×1080640输入会导致小目标严重缩小。最佳策略是先裁切或保持原始比例而不是无脑压缩。batch16根据显存调整。1304张图不算多batch过小会训练不稳定。patience30验证集指标30轮没有提升就提前停止节省时间。device0,1使用多卡训练数据量不大时单卡也够。如果不走命令行用Python也能写训练脚本from ultralytics import YOLO model YOLO(yolov8n.pt) results model.train( datariver_trash.yaml, epochs200, imgsz640, batch16, lr00.01, patience30, device0 )4.3 数据增强在河道垃圾场景中的核心作用水面垃圾检测最大的难点是光照变化剧烈、白色垃圾与水花反光混淆、目标尺度从几十像素到几百像素不等。YOLOv8自带Mosaic、MixUp、HSV扰动、随机翻转等增强策略。训练水面垃圾数据集时我一般会加大hsv_v和hsv_h模拟早晨和傍晚的色温变化。yolo detect train \ ... \ hsv_h0.02 \ hsv_s0.8 \ hsv_v0.6 \ degrees90 \ translate0.2 \ scale0.5 \ fliplr0.5degrees90允许90度旋转因为河流摄像头角度不固定。translate0.2水平垂直平移增强漂浮物出现在画面边缘的情况。scale0.5允许目标缩放模拟垃圾在不同距离下的尺度变化。增强开得太大反而会让模型学到错误形状建议先小规模试跑50轮查看验证集loss趋势再逐步加大。4.4 模型评估与结果解读训练结束后用val集评估yolo detect val \ datariver_trash.yaml \ modelruns/detect/train/weights/best.pt \ plotsTrue输出会包含多个指标核心表格如下指标含义对我们的意义mAP50IOU0.5下的平均精确率衡量检测框是否大致落在垃圾上mAP50-95IOU从0.5到0.95的平均衡量边界框边界的贴合程度Precision预测框中有多少是真正垃圾减少误报避免把水面反光识别为垃圾Recall真实垃圾中有多少被找到减少漏检保证清理船能覆盖所有目标水面垃圾场景中我更关注Recall。因为漏检一个塑料瓶影响不大但漏检一整堆垃圾会让清理船做出错误路线规划。如果mAP50-95不够理想第一反应是检查数据质量问题而不是更换更重的模型。5. 数据验证与难例分析让河道垃圾检测模型落地5.1 类别分布与标签质量检查模型训练完成后回看数据集本身常有意外发现。我习惯用下面这个脚本生成一份训练集报告import os import random from pathlib import Path images_dir images/train labels_dir labels/train sample_count 10 all_labels list(Path(labels_dir).glob(*.txt)) empty_labels 0 tiny_boxes 0 for label_file in all_labels: with open(label_file) as f: lines f.readlines() if len(lines) 0: empty_labels 1 for line in lines: parts line.split() w float(parts[3]) h float(parts[4]) if w 0.01 or h 0.01: tiny_boxes 1 print(f空标签文件: {empty_labels}) print(f过小边界框: {tiny_boxes})“过小边界框”这里定义为归一化宽高都小于0.01在实际640×640输入下不到6像素这种目标在模型中几乎不可能被检出。如果该比例超过5%建议单独收集一批近景图像做成第二个数据集或者对原图做切片处理。5.2 用验证集做难例挖掘YOLOv8的预测结果可以输出检测框的置信度。把验证集上置信度在0.20.5之间的预测框拿出来逐一检查是漏检还是误检能快速定位模型弱点。from ultralytics import YOLO model YOLO(runs/detect/train/weights/best.pt) results model.predict( sourceimages/val, save_txtTrue, conf0.2, save_confTrue ) for r in results: boxes r.boxes for box in boxes: conf box.conf.item() if 0.2 conf 0.5: print(r.path, box.xyxy.tolist(), conf)把这些图片提取到单独目录人工标记出“水纹反光误检”“遮挡漏检”“小目标漏检”三类。然后再按对应问题调整数据增强或后处理参数比如把conf阈值从0.25下调到0.15虽然会引入部分误检但配合NMS的IOU0.45往往能找回被压制的重复框。5.3 小目标检测的优化思路河道漂浮物中大量是小目标一个直观技巧是把imgsz从640提升到960或1280但显存消耗成倍增加。另一个做法是Tiling推理把原图切成四个640×640的子图分别预测再将结果映射回原坐标。切片推理在小目标数据集上的mAP提升可以达到5~8个点代价是推理时间增加到原来的4倍。具体操作时我会用sahi库集成YOLOv8快速实现切片预测from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction detection_model AutoDetectionModel.from_pretrained( model_typeyolov8, model_pathruns/detect/train/weights/best.pt, confidence_threshold0.3, image_size640, devicecuda:0 ) get_sliced_prediction( IMG_20240501_103205.jpg, detection_model, slice_height640, slice_width640, overlap_height_ratio0.2, overlap_width_ratio0.2, postprocess_class_agnosticFalse )这里overlap设为0.2是为了避免垃圾恰好被切片边界切断。如果训练时用了中等强度增强切片推理时不要关闭TTA这样会把更多被抖动的目标重新拉回来。最终验证集的结果通常是决定这个模型能否安装到边缘设备的关键数字。本文还有配套的精品资源点击获取