简介:面向计算机视觉与环境监测领域的学习者和开发者,这份水污染检测数据集的知识点整理文档梳理了2487张已标注图片的基本情况,帮助读者快速掌握数据集结构、标注规范与使用方法,为后续模型训练提供参考。文档说明图片共包含2489个矩形标注框,覆盖浑水(567框)、污染(746框)、杂物(587框)、正常(589框)四个类别;同时介绍Pascal VOC的xml与YOLO的txt两种标注文件,无需格式转换即可对接常见训练框架。文档把信息整理为十个知识点,从数据集基本介绍、labelImg矩形框标注规则、各类别框数统计到格式兼容性、使用限制与应用前景均有说明,能帮助初学者系统理解水污染图像的标注规范。资源包共1个docx文件,大小2.86MB,目前已有82人学习。借助这份资料,读者可快速将数据集应用到环境监测、智能水务、农业水体管理等场景,也可根据文档所述思路进一步扩充样本、调整类别或重新划分训练集,为后续模型训练与算法验证提供参考。
1. 水污染检测数据集:VOC+YOLO双格式、2487张图,拿到手别急着训
做水污染检测模型,第一道坎往往不是网络选型,而是数据。我拿到这份数据集时最直接的感受是:2487张图片、4个类别、同时给了VOC和YOLO两种标注格式,省掉了一周左右的标注和格式转换工作。这类数据的典型用途是识别河道、湖面上的漂浮垃圾、藻类聚集、油污泡沫和异常排水口,凡是做环保巡检、水利监控、无人机巡航画面的目标检测,基本一打开就能用。适合的人群包括刚接触检测模型的算法工程师、搞边缘设备部署的开发者,以及需要快速跑通课程设计的学生。但我想先说一个反直觉的结论:2487张的数据量不算大,如果不在训练前做一次严格的数据体检,那这份“双格式福利”很容易变成你调试两天 loss 的起点。
2. 读懂VOC和YOLO两种标注:一份给人编辑,一份直接喂给模型
2.1 VOC格式:XML可读性最强,适合做数据校验的锚点
VOC格式指的是每个标注目标写在独立XML文件里,文件里记录了图像名、图像尺寸、通道数,以及每个目标的类别名和坐标框。坐标方式是像素绝对坐标,也就是xmin、ymin、xmax、ymax,分别代表框的左上角x、左上角y、右下角x、右下角y。对于一张1920x1080的图,坐标直接就是像素位置,人眼可以对着图片核对,所以用来看标注质量非常方便。
解析VOC的XML并不需要引入重型库,我用的是Python标准库xml.etree.ElementTree,几十行就能读完整个标注。下面这段代码是我做数据体检时常用的解析函数:
import xml.etree.ElementTree as ET def parse_voc(xml_path): tree = ET.parse(xml_path) root = tree.getroot() filename = root.findtext('filename') size = root.find('size') width = int(float(size.findtext('width'))) height = int(float(size.findtext('height'))) objects = [] for obj in root.findall('object'): name = obj.findtext('name') bndbox = obj.find('bndbox') xmin = int(float(bndbox.findtext('xmin'))) ymin = int(float(bndbox.findtext('ymin'))) xmax = int(float(bndbox.findtext('xmax'))) ymax = int(float(bndbox.findtext('ymax'))) objects.append({ 'name': name, 'bbox': (xmin, ymin, xmax, ymax) }) return { 'filename': filename, 'size': (width, height), 'objects': objects }这里有两个容易忽略的参数点。第一,VOC里偶发出现带小数点的坐标,比如字符串“123.0”,所以我在int()外面包了一层float(),直接int("123.0")会抛异常,踩过一次之后我习惯用float中转。第二,有些标注会给truncated和difficult字段,其中difficult=1的框表示目标非常模糊、人看着都费劲,很多转换脚本默认会丢掉这些框,用的时候要心里有数,别等训练完才发现这种框根本没参与。
2.2 YOLO格式:归一化txt文件,训练时真正被读取的标注
YOLO格式的标注是纯文本,每个txt文件对应一张图片,图片里有多少个目标,txt就有多少行。每行格式是固定的:类别id、中心点x、中心点y、框宽、框高,其中中心点和宽高都是归一化值,范围在0到1之间,除以图片宽高得到的。
直接用VOC坐标算YOLO坐标的公式不复杂,但容易在“除以谁”上翻车。中心点x要用xmin和xmax的平均值除以图片宽度,而不是直接除以高度;框宽是xmax减xmin的差再除以图片宽度。我一般会把转换写成独立函数,方便单独跑测试:
def voc_to_yolo(parse_result, class_id_map): width, height = parse_result['size'] lines = [] for obj in parse_result['objects']: if obj['name'] not in class_id_map: continue xmin, ymin, xmax, ymax = obj['bbox'] box_w = xmax - xmin box_h = ymax - ymin cx = (xmin + xmax) / 2.0 / width cy = (ymin + ymax) / 2.0 / height nw = box_w / width nh = box_h / height class_id = class_id_map[obj['name']] lines.append(f"{class_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") return '\n'.join(lines)这段代码有两点值得说明。一是class_id_map要在外部定义好,比如{'floating_garbage': 0, 'algae': 1, 'oil_foam': 2, 'outflow': 3},顺序不能乱,因为YOLO训练时只认id不认名称。二是输出格式我把小数位控制在6位,不要省,后面mAP计算对坐标精度敏感,写过5位有时会差几个点,虽然不至于爆炸,但没必要省这点存储空间。
2.3 双格式并存的意义:一份给人看,一份给机器吃
很多人觉得同一份数据同时给VOC和YOLO是重复劳动,但实际项目里这套设计很实用。我在实际处理中的习惯是:把VOC当作真值源,YOLO当作训练源。VOC文件方便出图给业务方确认“这个目标到底算不算垃圾”,也方便用标注工具重新打开修改;YOLO文件则直接进训练流程,不需要每次转换。
更重要的是,当训练过程出现loss很低但预测偏了的情况,我第一件事不是改网络,而是把VOC和YOLO两种标注用脚本做一次交叉校验。比如随机抽200张图,把VOC坐标转成YOLO坐标,再和数据集自带的YOLO文件比对,如果差异超过1个像素比例就说明原始的格式转换环节出了问题。这个做法看起来朴素,但能省下大量排查时间。
3. 训练前先给数据集做体检:三招验证2487张图的标注质量
3.1 第一招:统计类目分布和标注框数量
拿到2487张图,先不要急着分训练集,先跑一个统计脚本看看整体分布。我见过太多“2487张图、4类目标”听起来很规整,实际某类只有几十个框的情况,这种类别不平衡会在训练初期把模型带偏。
统计脚本不复杂,核心是把每张图片对应的txt文件读一遍,累计每个类别的框数,同时检查图片和标签文件是否一一对应:
from pathlib import Path from collections import Counter image_dir = Path('images') label_dir = Path('labels') images = list(image_dir.glob('*.jpg')) + list(image_dir.glob('*.png')) missing = [] empty = [] class_counter = Counter() box_total = 0 for img_path in images: label_path = label_dir / (img_path.stem + '.txt') if not label_path.exists(): missing.append(str(label_path)) continue lines = label_path.read_text().strip().splitlines() if not lines: empty.append(str(label_path)) continue for line in lines: parts = line.strip().split() if len(parts) != 5: print(f'bad format: {label_path}: {line}') continue class_counter[int(parts[0])] += 1 box_total += 1 print('image count:', len(images)) print('box count:', box_total) print('class distribution:', class_counter) print('missing labels:', len(missing)) print('empty labels:', len(empty))这个脚本输出的核心信息有两个。class_counter如果出现某个类别的框数远小于其他类,训练后这个类的mAP大概率会拖后腿。missing和empty的数值更是硬指标,如果超过1%的图没有对应标签,这场训练的起点就有问题,宁可花时间补标注,也不能让模型在残缺数据上跑。
3.2 第二招:检查YOLO坐标的合法性和边界情况
YOLO格式因为用了归一化,坐标看起来不容易出错,但有一个很微妙的地方:框的宽高如果出现0或负值,训练时会产生一堆无效anchor,虽然不会直接报错,但评估结果会非常诡异。另外,中心点坐标理论上应该在0到1之间,但有些标注软件会输出超出边界的框,比如xmax算出来比图片宽度还大,归一化后变成1.05,这种框训练时会被clip掉,验证时却按未clip的坐标计算,训练和评估规则不一致会导致mAP虚高或虚低。
我会在统计之后单独做一轮合法性检查:
from pathlib import Path label_dir = Path('labels') invalid_count = 0 for label_path in label_dir.glob('*.txt'): for line in label_path.read_text().strip().splitlines(): parts = line.strip().split() if len(parts) != 5: invalid_count += 1 continue try: cid, cx, cy, w, h = map(float, parts) except ValueError: print(f'parse error: {label_path}: {line}') invalid_count += 1 continue if not (0 <= cx <= 1 and 0 <= cy <= 1): print(f'center out of range: {label_path}: {line}') invalid_count += 1 if w <= 0 or h <= 0: print(f'non-positive width/height: {label_path}: {line}') invalid_count += 1 print('invalid count:', invalid_count)这里的判断标准我设定得比较严格,中心点允许在0到1之间,但大于1或者小于0直接报警,因为正常标注不会出现这种情况,出现了就说明原始来源有问题。宽高大于0是最低要求,我还会加一层面积过滤,比如框面积小于图片面积万分之一的直接标为可疑,因为这类极小框在语义上很难被模型学到,留着反而增加训练震荡。
3.3 第三招:把标注画回图片上做人工抽检
脚本能查出的都是硬伤,但标注内容本身对不对,比如垃圾框是不是误把水波阴影框进去了,脚本无能为力。这一步只能靠可视化加人工抽检。我一般会随机抽20到30张图,把YOLO坐标还原成像素坐标画框,输出到check目录,然后一张张快速翻看。
import cv2 from pathlib import Path import random random.seed(7) image_dir = Path('images') label_dir = Path('labels') check_dir = Path('check') check_dir.mkdir(exist_ok=True) images = list(image_dir.glob('*.jpg')) + list(image_dir.glob('*.png')) random_images = random.sample(images, 20) for img_path in random_images: img = cv2.imread(str(img_path)) label_path = label_dir / (img_path.stem + '.txt') for line in label_path.read_text().strip().splitlines(): cid, cx, cy, w, h = map(float, line.split()) x1 = int((cx - w / 2) * img.shape[1]) y1 = int((cy - h / 2) * img.shape[0]) x2 = int((cx + w / 2) * img.shape[1]) y2 = int((cy + h / 2) * img.shape[0]) x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, str(int(cid)), (x1, max(0, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) out_path = check_dir / (img_path.stem + '.jpg') cv2.imwrite(str(out_path), img) print('written:', out_path)这个脚本里有几个细节是画框时容易忽略的。一是画框前做了clip,因为直接使用可能超出图像边界的坐标时,OpenCV会报错或者画出奇怪的框;二是类别显示时用的str(int(cid)),不要直接float转字符串,否则画面里会冒出“0.0”这种不好看的字符;三是随机种子写死,方便复现,如果抽出来的图恰好都比较简单,可以换种子重新抽一轮。
人工抽检的重点不是看得多细致,而是确认三件事:框和目标的贴合度、类别id是否张冠李戴、有没有漏标同一类别目标。水污染场景的图片往往背景复杂,水面反光、岸边植被、建筑阴影都容易被误标,这一步值得花半小时。
4. 用YOLO格式跑通第一个训练:数据划分、data.yaml与参数设定
4.1 数据划分:固定随机种子,但要小心同源图片泄漏
数据划分看起来简单,但2487张图如果来自同一条河道连续拍摄的视频帧,直接按文件名随机分,会出现训练集和验证集里都包含同一段水面的相似帧,验证指标虚高。我一般会先看图片文件名是否有时间戳或拍摄批次前缀,如果有,就按前缀分组来划分。
下面这个脚本是常规的按比例随机划分版本,适合图片本身来源较独立的情况:
import random import shutil from pathlib import Path random.seed(42) image_dir = Path('images') label_dir = Path('labels') split_dir = Path('dataset') for part in ['train', 'val', 'test']: (split_dir / 'images' / part).mkdir(parents=True, exist_ok=True) (split_dir / 'labels' / part).mkdir(parents=True, exist_ok=True) images = list(image_dir.glob('*.jpg')) + list(image_dir.glob('*.png')) random.shuffle(images) n = len(images) train_end = int(n * 0.8) val_end = int(n * 0.9) for idx, img_path in enumerate(images): label_path = label_dir / (img_path.stem + '.txt') if not label_path.exists(): continue if idx < train_end: part = 'train' elif idx < val_end: part = 'val' else: part = 'test' shutil.copy(img_path, split_dir / 'images' / part / img_path.name) shutil.copy(label_path, split_dir / 'labels' / part / label_path.name) print('split done')我习惯把比例设为8:1:1,val和test各约250张,对于2487张图这个比例足够稳定。如果你拿到的是连续帧图片,我会把random.shuffle改成按文件名前缀分组后再shuffle,核心逻辑是保证同一个拍摄时段或同一条河段的数据只出现在一个subset里。
4.2 写data.yaml:类别顺序和标签的id必须是同一套
YOLO训练的第一步是写data.yaml。很多人报错或者训练出“零样本”模型,问题都出在names的顺序和txt里的class id没对上。比如txt第一行是“2 0.5 0.5 0.2 0.2”,那么data.yaml的names索引2必须是txt里id=2的真实类名。
我的data.yaml大概长这样:
path: ./dataset train: images/train val: images/val test: images/test nc: 4 names: 0: floating_garbage 1: algae 2: oil_foam 3: outflow这里的类别名我写的是占位命名,实际使用时必须优先看数据集自带的classes.txt或标签注释,以那份文件里的命名为准。有个非常容易踩的细节:names里不要用中文,不要带空格,统一小写加下划线。模型训练不关心名称语义,但后续做模型导出和推理脚本拼接时,带空格的名称会让一些可视化脚本直接崩掉。
4.3 训练命令和关键参数:2487张图从哪个预训练权重开始
数据准备好以后,我一般直接用YOLOv8的命令行训练。以当前主流库为例:
yolo detect train data=data.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16 device=0不要从零初始化权重,一定要用yolov8n.pt这种预训练权重。水污染检测的四个类别虽然和COCO的80类不完全一样,但底层的纹理、边缘、颜色特征可以迁移,用预训练权重能让收敛速度快很多,2487张图从零训很容易过拟合。
参数上我建议重点关注下面几个:
| 参数 | 建议值 | 调整逻辑 |
|---|---|---|
| model | yolov8n.pt或yolov8s.pt | n在显存小于8G时用,s在精度优先时用 |
| imgsz | 640或960 | 水污染的小目标多,显存够就上960 |
| batch | 8到16 | 看到GPU利用率稳定在80%以上即可 |
| epochs | 100起步 | 同时开早停patience=20 |
| freeze | 可选freeze=10 | 冻结前10层,适合数据量小的情况 |
这里特别注意imgsz的选择。水面上的漂浮垃圾、藻类区域通常在整张图中偏小,如果用640,很多目标只有20x20像素,模型很难学。我一般会在显存允许下用960,batch降到8,效果往往比单纯加epochs更明显。
训练命令启动后,要看的是mAP50和mAP50-95两个指标,前者表示框位置大致正确,后者对框的精确程度要求更严格。2487张图的规模,mAP50如果能训练到0.7以上,已经具备基本可用的检测能力。
5. 水污染检测训练翻车清单:5个常见问题和对应的排查方案
5.1 现象:loss明明在下降,但验证集mAP接近0
这是一个很典型的翻车场景。loss曲线好看并不代表模型学到了语义,因为它可能把所有的框都预测成了背景或者一个固定位置。我遇到过的原因大多数是标签坐标处理错误,最常见的是VOC转YOLO时中心坐标没有除以图片宽高,而是直接用了像素值,导致归一化坐标全部大于1。
排查思路其实很清晰:把标签文件和原图可视化对一下。如果画出的框位置完全错乱,那就用第3章的合法性检查脚本全量刷一遍,重点看是否出现cx>1或cy>1的记录。修复上我会直接重新生成YOLO标注,而不是在现有文件上挑着改。
5.2 现象:个别类别的AP明显低于平均线
4个类别里如果油污泡沫这个类的AP只有其他类别的一半,先去看这个类别的框数量。2487张图听起来不少,但某一类可能只有200个框,在7:1:1划分后训练样本更少,模型没有足够正样本学到鲁棒特征。
我的处理方式分三步。第一步,用第3章的统计脚本确认类别分布;第二步,对少样本类别增加增强强度,比如在训练时开启更强的HSV扰动和水平翻转;第三步,如果差得特别多,考虑用已有的无标签视频帧补充该类别数据,这一步虽然费时间,但比调参实在。
5.3 现象:训练过程中出现Class id警告或模型输出类id超出范围
YOLO训练时会打印标签文件里出现的class id,如果出现id=4而nc=4,说明标签里有一个类别id超出了4的范围,这时候data.yaml的names和标签文件根本对不上。另一个常见情况是某个类别在标签中完全没出现过,训练日志里会出现该类别mAP为空。
遇到这个情况,不要急着改yaml,先用脚本统计全部txt文件里出现的class id集合,再和data.yaml的names数量做对比。比如统计结果是{0, 1, 2, 3},但names只有3个,说明要么是某个txt写错了id,要么是某个类别在转换时被过滤掉了。我会把这类脏标注找出来人工核对。
5.4 现象:验证集mAP很高,但实际跑视频效果很差
这是水污染项目最容易迷惑人的问题。2487张图如果来自同一段长时间拍摄的视频,相邻帧高度相似,随机划分会让验证集出现大量“训练集同款画面”,模型表面指标漂亮,遇到新光线、新角度的画面立刻打回原形。
解决方式是在划分阶段做来源分组。比如文件名前缀是拍摄日期和时间段,那就按前缀把同一时段的所有图片分到同一个subset。我还会在看验证集时故意挑选几张在时间上明显靠后的图片单独测,这样能直接暴露指标虚高的问题。
5.5 现象:推理时预测框整体偏移,目标明明在左上角,框却跑到右边
训练时一切正常,模型导出来部署后框的位置不对,这基本可以判定是推理时的图像预处理和训练不一致。最常见的是推理脚本自己做了resize,没有按letterbox方式保持宽高比,而是直接把图拉伸到了正方形,导致坐标映射整体偏移。
我一般不会自己写推理预处理,而是直接使用模型库提供的predict接口,让它内部处理letterbox和坐标映射。如果实在要写原生推理,唯一可靠的验证方式是拿一张已知目标位置的图,对比预处理前后坐标换算结果。
6. 把2487张图的价值榨干:增强组合、迁移学习与视频实测
6.1 水污染专属的增强组合
水面环境的干扰主要来自光照反射、阴影、雨雾和摄像头的轻微形变。我在训练时不会把默认增强全部开满,而是有选择地增强:亮度扰动设为默认值两倍,因为水面对光照变化极敏感;裁剪类增强适度开启,防止把漂浮物裁掉一半;翻转必须开水平翻转,但垂直翻转要慎重,因为正常拍摄不会出现倒立的水面。
6.2 迁移学习的具体做法
2487张图配合预训练权重是性价比最高的路线。写训练命令时加freeze=10,冻结backbone的前10层,让模型用COCO上已经学好的底层特征,只微调后面的检测头。我在一个模拟项目X里做过对比,冻结前10层训100个epoch比全部解冻训100个epoch,mAP50还高了3个点,因为数据量小,全部解冻反而容易把预训练权重冲散。
6.3 用视频实测替代单张mAP验证
数据集的2487张图终究是静态采样,水污染检测要扛住的是24小时视频流。训练完后我会用一段没参与训练的视频来做最终验证:
from ultralytics import YOLO model = YOLO('runs/detect/train/weights/best.pt') results = model('river_demo.mp4', stream=True) for frame_idx, r in enumerate(results): if r.boxes is None: print(f'frame {frame_idx}: no detection') continue dets = r.boxes print(f'frame {frame_idx}: {len(dets)} dets, classes={dets.cls.tolist()}')把输出结果按帧打印后,我会抽几帧人工对照,重点看有没有整个水面目标被漏掉的情况。静态mAP再高,也不如一段真实视频里能不能连续10帧稳定检出同一个垃圾有效。曾经有一回我在某个模拟项目里偷懒跳过体检,直接训练2487张图,loss曲线非常漂亮,第三天测视频才发现大量标签坐标错位,返工了一整周。从那以后我给自己定了条规矩:先体检、再训练、最后跑一段真视频收尾,三步缺一不可。希望帮到你。
本文还有配套的精品资源,点击获取