ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

水污染检测数据集实战:2487张图VOC/YOLO双格式转换与YOLO训练避坑指南

水污染检测数据集实战:2487张图VOC/YOLO双格式转换与YOLO训练避坑指南

简介:面向环境监测、计算机视觉与深度学习方向的研究者与开发者,这份水污染检测数据集以2487张已标注jpg图像为核心,覆盖浑水、污染、杂物、正常四个类别,共2489个矩形标注框,可支撑水质异常识别、目标检测训练与算法验证等任务。资源包为单个docx文档,约2.86MB,主要承载数据集说明、类别统计与使用规范,图像与标注文件按VOC的xml和YOLO的txt双格式组织,兼容不同训练框架的数据读取习惯。已有83人学习关注。文档中给出了各类别框数分布、labelImg标注工具与矩形框标注规则,并明确不对训练模型精度作保证,需使用者自行验证数据有效性;同时说明了双格式兼容带来的适用性优势,以及在智能城市水体管理、农业水体监测、工业污水处理等场景的应用前景,便于读者快速判断数据规模与标注质量是否匹配自身实验需求。

1. 水污染检测数据集落地:2487 张图、4 类目标,先搞清楚它能干什么

手上有一批水体巡检的图片,想训一个能自动框出「浑水、污染、杂物、正常」四类目标的检测模型,最卡脖子的往往不是网络结构,而是标注数据从哪来。这份水污染检测数据集就是冲着这个缺口来的:2487 张 jpg 图片,每张都配了 Pascal VOC 的 xml 和 YOLO 的 txt 两套标注,4 个类别合计 2489 个矩形框,用 labelImg 画的。它解决的是「从零标注两千多张图」这件又贵又慢的事,适合做环境监测视觉算法、水质巡检 Demo、或者拿来做目标检测课程设计的人。下面按「这份数据长什么样 → 怎么转成能训的格式 → 训练时参数怎么设 → 哪里会翻车」一路拆下去,能直接抄的脚本我都贴出来。

2. 数据集结构与类别分布:先摸清 2487 张图的底细

拿到一个数据集,我第一件事不是急着写训练脚本,而是先把目录结构和类别分布摸清楚。这一步花十分钟,能省掉后面几个小时的报错排查。这份数据的组织方式比较典型:图片和两套标注是平铺或分目录存放的,VOC 的 xml 和 YOLO 的 txt 一一对应同一张 jpg,文件名主干一致。先跑一遍统计脚本,把「图片数、标注数、每类框数、有没有空标注、有没有孤儿文件」全部拉出来,心里才有数。

2.1 目录约定与文件对应关系

按项目说明,数据集只包含 jpg 图片、VOC 格式的 xml、YOLO 格式的 txt,不含分割路径的 txt 文件。也就是说它不是标准的 YOLO 分割数据集,别指望里面有train.txt/val.txt这种划分清单,划分得自己来做。常见的目录长这样:

dataset/ ├── JPEGImages/ # 2487 张 jpg ├── Annotations/ # 2487 个 VOC xml ├── labels/ # 2487 个 YOLO txt │ └── classes.txt # 类别顺序以此为准 └── (自行生成的) images/ labels/ train.txt val.txt

这里有个关键点必须强调:项目正文明确写了「yolo 格式类别顺序不和类别名称列表对应,而以 labels 文件夹 classes.txt 为准」。这句话是整份数据集最容易踩的雷。VOC 的 xml 里存的是类别名字符串,YOLO 的 txt 里存的是类别索引数字,两套顺序如果不一致,你按 xml 的顺序去理解 txt,训出来的模型就会把「浑水」认成「正常」。所以第一步永远是打开classes.txt确认索引映射,而不是想当然按["hunshui","wuran","zawu","zhengchang"]的顺序去对。

2.2 用脚本统计类别分布与标注健康度

下面这段脚本把图片、xml、txt 三者的对应关系和每类框数一次性盘清楚,顺便检查空标注和孤儿文件。放在数据集根目录跑即可。

import os import glob from collections import Counter import xml.etree.ElementTree as ET ROOT = "." # 数据集根目录 img_dir = os.path.join(ROOT, "JPEGImages") xml_dir = os.path.join(ROOT, "Annotations") txt_dir = os.path.join(ROOT, "labels") # 1. 读 classes.txt,确定 YOLO 索引 -> 类别名 的映射 with open(os.path.join(txt_dir, "classes.txt"), "r", encoding="utf-8") as f: classes = [line.strip() for line in f if line.strip()] print("YOLO 索引映射:", {i: c for i, c in enumerate(classes)}) # 2. 统计 VOC xml 里每类框数(按类别名) voc_counter = Counter() xml_files = glob.glob(os.path.join(xml_dir, "*.xml")) for xf in xml_files: tree = ET.parse(xf) for obj in tree.getroot().findall("object"): name = obj.find("name").text.strip() voc_counter[name] += 1 print("VOC 各类框数:", dict(voc_counter), "总计:", sum(voc_counter.values())) # 3. 统计 YOLO txt 里每类框数(按索引),并检查空标注 yolo_counter = Counter() empty_txt = [] txt_files = glob.glob(os.path.join(txt_dir, "*.txt")) for tf in txt_files: if os.path.basename(tf) == "classes.txt": continue with open(tf, "r") as f: lines = [l for l in f.readlines() if l.strip()] if not lines: empty_txt.append(tf) for l in lines: cls_id = int(l.split()[0]) yolo_counter[cls_id] += 1 print("YOLO 各类框数:", {classes[k]: v for k, v in yolo_counter.items()}) print("空标注 txt 数量:", len(empty_txt)) # 4. 检查图片 / xml / txt 三者是否一一对应 img_stems = {os.path.splitext(os.path.basename(p))[0] for p in glob.glob(os.path.join(img_dir, "*.jpg"))} xml_stems = {os.path.splitext(os.path.basename(p))[0] for p in xml_files} txt_stems = {os.path.splitext(os.path.basename(p))[0] for p in txt_files if os.path.basename(p) != "classes.txt"} print("图片数:", len(img_stems), "xml 数:", len(xml_stems), "txt 数:", len(txt_stems)) print("有图无 xml:", len(img_stems - xml_stems)) print("有 xml 无图:", len(xml_stems - img_stems)) print("有图无 txt:", len(img_stems - txt_stems))

逻辑说明:第 1 段先把classes.txt读成索引到名字的字典,这是后面所有映射的基准;第 2、3 段分别从 xml 和 txt 两个来源统计框数,正常情况下两边总数应该都是 2489,如果对不上,说明某套标注有缺失或类别名写错;第 4 段做集合差,把「有图没标注」「有标注没图」的孤儿文件揪出来。参数上唯一要改的是ROOT,如果你的目录名不是JPEGImages/Annotations/labels,把三个路径变量改成实际路径即可。

跑完你会得到类似这样的预期结果:图片 2487、xml 2487、txt 2487,VOC 侧hunshui 567 / wuran 746 / zawu 587 / zhengchang 589,总计 2489。注意总框数 2489 比图片数 2487 只多 2,说明绝大多数图片只有 1 个框,少量图片有 2 个框。这个分布对训练有两个直接影响:一是单图目标少,模型容易学到「一张图就一个目标」的先验,做密集场景推理时会漏检;二是四类框数在 567 到 746 之间,相对均衡,不需要做重采样,但「污染」类最多、「浑水」类最少,差了一百多框,小类别仍然值得在增强上多给一点权重。

2.3 类别不均衡与单图目标稀疏的处理取舍

承接上面的统计,四类框数最大最小比约 1.3:1,这个比例在目标检测里算相当温和的,不需要上 focal loss 或者类别加权这种重手段。真正要留意的是「正常」这一类——它框的是「水质正常」的区域,语义上是个偏「背景」的类,模型很容易把它和背景混淆,表现为把正常水体误判成污染,或者反过来。我的做法是在验证集里单独把「正常」类的 precision/recall 拎出来看,而不是只看整体 mAP。如果「正常」类 recall 明显偏低,优先检查标注框是不是把整片水面都框进去了、框得过大,而不是急着调学习率。

另外单图目标稀疏这件事,增强策略上要克制。Mosaic 这类把四张图拼一张的增强,在单图单目标的数据上容易让小目标被裁切得只剩半截,反而引入噪声。我一般把 Mosaic 的关闭轮次提前,比如总 epoch 的 80% 就关掉,让模型在后段见到真实分布。这些取舍没有标准答案,但先看清分布再决定,比无脑套默认配置靠谱得多。

3. VOC 与 YOLO 双格式转换:坐标归一化与类别索引对齐

这份数据集同时给了 VOC 和 YOLO 两套标注,好处是你用哪个框架都能接,坏处是两套格式的坐标体系和类别表示完全不同,转换时错一个地方就是静默的错误——训练不报错,但模型学的是错的东西。这一章把转换的数学原理、脚本实现和校验方法讲透,照着做能保证 xml 和 txt 描述的是同一个框。

3.1 两种格式的坐标定义差异

VOC 的 xml 里,bndbox存的是xmin, ymin, xmax, ymax,单位是像素,原点在左上角,是绝对坐标。YOLO 的 txt 每行是class_id x_center y_center width height,全部归一化到 0 到 1 之间,x_center/y_center是框中心点相对图宽高的比例,width/height是框宽高相对图宽高的比例。转换公式不复杂,但有两个细节经常被写错:

第一,归一化的分母是图片的真实宽高,必须从 jpg 读,不能假设固定尺寸。这份数据集的图片尺寸未必统一,写死 640 会直接让框偏移。第二,VOC 的坐标是闭区间语义(xmax 那个像素属于框内),转成宽高时用xmax - xmin还是xmax - xmin + 1,不同标注工具习惯不同。labelImg 导出的 xml 一般按xmax - xmin处理,跟 YOLO 的连续坐标语义一致,所以直接用差值即可,加 1 反而会让框整体偏大一点点。这种一两个像素的偏差在 2487 张图的尺度上不至于毁掉训练,但如果你后面要做高精度小目标,值得统一口径。

3.2 从 xml 批量生成 YOLO txt 的脚本

如果哪天你只有 xml 没有 txt,或者想验证现有 txt 和 xml 是否一致,用下面这段脚本从 xml 重新生成 txt,再和原始 txt 做逐行比对。脚本里类别索引严格按classes.txt的顺序取,不按 xml 里类别出现的顺序。

import os import glob import xml.etree.ElementTree as ET from PIL import Image ROOT = "." xml_dir = os.path.join(ROOT, "Annotations") img_dir = os.path.join(ROOT, "JPEGImages") out_dir = os.path.join(ROOT, "labels_regen") os.makedirs(out_dir, exist_ok=True) # 类别名 -> 索引,顺序以 classes.txt 为准 with open(os.path.join(ROOT, "labels", "classes.txt"), "r", encoding="utf-8") as f: classes = [l.strip() for l in f if l.strip()] name2id = {name: i for i, name in enumerate(classes)} for xf in glob.glob(os.path.join(xml_dir, "*.xml")): stem = os.path.splitext(os.path.basename(xf))[0] img_path = os.path.join(img_dir, stem + ".jpg") if not os.path.exists(img_path): print("缺图,跳过:", stem) continue w, h = Image.open(img_path).size # 真实宽高,不能用固定值 lines = [] tree = ET.parse(xf) for obj in tree.getroot().findall("object"): name = obj.find("name").text.strip() if name not in name2id: print("未知类别:", name, "in", stem) continue b = obj.find("bndbox") xmin = float(b.find("xmin").text) ymin = float(b.find("ymin").text) xmax = float(b.find("xmax").text) ymax = float(b.find("ymax").text) # 裁剪到图像边界,防止标注越界导致归一化后超出 [0,1] xmin, xmax = max(0, xmin), min(w, xmax) ymin, ymax = max(0, ymin), min(h, ymax) xc = (xmin + xmax) / 2.0 / w yc = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{name2id[name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") with open(os.path.join(out_dir, stem + ".txt"), "w") as f: f.write("\n".join(lines))

逻辑说明:核心是name2id用classes.txt建映射,保证生成的索引和数据集自带的 txt 同一套顺序;Image.open().size拿真实宽高,这是归一化正确的前提;边界裁剪那两行是防御性的,标注工具偶尔会画出越界框,不裁的话归一化后会出现大于 1 或小于 0 的值,某些训练框架会直接报错或静默丢弃。参数上out_dir故意输出到新目录,避免覆盖原始标注,方便比对。

生成完之后做一致性校验:把labels_regen和原始labels里同名 txt 逐行读出来,按类别索引和坐标做数值比对,容差设 1e-4。如果大量不一致,八成是类别顺序问题——要么原始 txt 的索引不是按classes.txt排的,要么 xml 里的类别名有拼写变体(比如大小写、空格)。这一步是血泪经验:我曾经因为一个类别名末尾多了个空格,导致该类所有框被跳过,训练集里直接少了一整类,loss 曲线看着正常,验证时才发现某一类 recall 是 0。

3.3 划分训练集与验证集的可复现做法

数据集不带划分清单,得自己切。2487 张图,我一般按 8:2 切,验证集约 497 张。关键是「可复现」和「类别分布一致」:用固定随机种子打乱,切完之后再统计一次两边各类框数,确认没有某一类在验证集里几乎消失。下面这段直接生成 YOLO 训练要用的train.txt/val.txt,每行是图片绝对路径。

import os, glob, random from collections import Counter ROOT = os.path.abspath(".") img_dir = os.path.join(ROOT, "JPEGImages") txt_dir = os.path.join(ROOT, "labels") random.seed(42) # 固定种子,保证每次划分一致 imgs = sorted(glob.glob(os.path.join(img_dir, "*.jpg"))) random.shuffle(imgs) split = int(len(imgs) * 0.8) train, val = imgs[:split], imgs[split:] with open("train.txt", "w") as f: f.write("\n".join(train)) with open("val.txt", "w") as f: f.write("\n".join(val)) # 校验两边类别分布 def count_cls(paths): c = Counter() for p in paths: stem = os.path.splitext(os.path.basename(p))[0] tf = os.path.join(txt_dir, stem + ".txt") if os.path.exists(tf): for l in open(tf): if l.strip(): c[int(l.split()[0])] += 1 return c print("train 框数:", dict(count_cls(train))) print("val 框数:", dict(count_cls(val)))

逻辑说明:random.seed(42)是复现的关键,换机器、换时间跑结果都一样;sorted之后再 shuffle,避免依赖文件系统返回顺序。切完打印两边框数,如果验证集某类框数是个位数,说明随机切得不巧,可以换种子重切,或者改成按类别分层抽样。这一步别省,验证集分布歪了,后面调参全是玄学。

4. 训练配置与参数落地:从数据 yaml 到第一轮收敛

数据准备好了,接下来是把它喂进训练框架。这份数据集是标准的目标检测矩形框标注,YOLO 系列是最省事的入口,下面以常见的 YOLO 训练配置为例讲参数怎么设。不绑定具体版本号,因为不同小版本的字段名略有差异,但核心字段是通用的,你按自己环境的文档对一下即可。

4.1 数据配置文件怎么写

YOLO 训练需要一个 yaml 描述数据路径和类别。基于前面的划分,配置大概长这样:

# data_water.yaml train: /abs/path/train.txt val: /abs/path/val.txt nc: 4 names: 0: hunshui 1: wuran 2: zawu 3: zhengchang

这里names的顺序必须和classes.txt完全一致,nc是 4。路径用绝对路径最稳,相对路径在不同工作目录下跑容易找不到文件。如果你的框架要求path字段加相对子路径的写法,按它的文档改,但names的索引映射不能动。这一步错了,训练照样跑,但类别全乱,属于最隐蔽的翻车点。

4.2 关键超参的取值理由

2487 张图、4 类、单图目标稀疏,这套数据量属于「小到中等」。我的起步配置和理由如下:

参数建议值理由
输入尺寸 imgsz640通用起点,水体目标通常占画面比例不小,640 够用;显存紧张可降到 512
batch162487 张图,16 的 batch 一轮约 155 步,梯度噪声适中;显存不够减到 8 并同步调小学习率
epochs100~150数据量不大,100 轮左右通常收敛,配合早停
初始学习率 lr00.01从零训练的标准起点;若加载预训练权重可降到 0.001
优化器SGD 或 AdamWSGD 泛化稳,AdamW 收敛快,小数据我偏向 SGD + 余弦退火
数据增强翻转 + 轻度色彩抖动水体颜色是重要线索,色彩抖动幅度要小,别把「浑水」抖成「正常」
Mosaic前 80% epoch 开启后段关闭,让模型见到真实单图分布

色彩抖动这条要单独说:这份数据的类别区分很大程度依赖水的颜色和浑浊度,「浑水」和「正常」在色调上差异明显。如果色彩增强开太猛(比如饱和度、亮度大幅随机),等于人为抹掉了类别线索,模型会学得很挣扎。我一般把 HSV 的 h/s/v 增益控制在 0.015/0.7/0.4 这个量级以内,比默认值保守。

4.3 启动训练与首轮观察点

配置齐了,启动命令大致是:

# 以常见 YOLO 训练入口为例,按你的环境替换权重和配置路径 python train.py \ --data data_water.yaml \ --weights yolov8n.pt \ --imgsz 640 \ --batch 16 \ --epochs 120 \ --lr0 0.01 \ --patience 30 \ --project runs/water \ --name exp1

参数说明:--weights加载预训练权重能显著加快小数据收敛,从零训练也可以但需要更多轮;--patience 30是早停,30 轮验证指标不升就停,省时间;--project/--name决定日志和权重输出目录,方便多次实验对比。启动后前 10 轮重点看三件事:一是 loss 是否稳定下降,如果 cls loss 震荡剧烈,多半是学习率偏大或标注里有异常框;二是验证集 mAP 是否在 20 轮内开始抬头,一直平说明数据配置或类别映射有问题;三是看几张验证集的预测可视化,确认框的位置和类别都对得上,这一步比看数字直观得多。

如果显存吃紧,优先降batch和imgsz,别急着改网络结构。2487 张图的数据量,模型容量不是瓶颈,数据质量和配置正确性才是。

5. 避坑与排查:这份数据集最容易翻车的五个地方

前面讲的是「怎么做对」,这一章讲「哪里会做错」。下面五条都是这类双格式数据集的高频翻车点,每条按现象、原因、解决来写,遇到问题时对着查。

5.1 类别索引错位:模型把「浑水」认成「正常」

现象:训练正常收敛,验证集整体 mAP 看着还行,但逐类看混淆矩阵,发现「浑水」和「正常」互相大量误判,其他类正常。

原因:YOLO txt 里的类别索引和classes.txt的顺序不一致,或者你在数据 yaml 里手写的names顺序和classes.txt不同。项目正文特意提醒过「yolo 格式类别顺序不和类别名称列表对应,而以 classes.txt 为准」,这句话就是针对这个坑的。

解决:以classes.txt为唯一基准,重新生成数据 yaml 的names;用第 3 章的脚本从 xml 重新生成一遍 txt,和原始 txt 比对,确认索引一致后再训。改完先跑 5 个 epoch 看混淆矩阵,别等训完 100 轮才发现。

5.2 归一化用了固定尺寸:框整体偏移

现象:预测框位置系统性偏移,尤其是图片长宽比不是 1:1 时,框会往某个方向偏,或者宽高比例不对。

原因:转换脚本里把图片宽高写死成 640×640,而实际图片尺寸不一。归一化分母错了,坐标自然全错。

解决:转换时用PIL.Image.open(path).size读真实宽高,逐图计算。改完抽查几张图,把 YOLO 框反算回像素坐标,和 xml 的bndbox对比,误差应在 1 像素内。

5.3 空标注与孤儿文件:训练报错或静默丢样本

现象:训练启动时报「找不到标签文件」,或者不报错但实际参与训练的图片数明显少于 2487。

原因:图片、xml、txt 三者文件名主干不完全一致(比如多了后缀、大小写差异),或者存在空 txt。某些框架遇到空标注会跳过该图,导致有效样本变少。

解决:跑第 2 章的统计脚本,看「有图无 txt」「空标注 txt 数量」两个指标。孤儿文件要么补标注要么删图,空标注图如果确实无目标,从 train.txt 里剔除,别让它占着位置。

5.4 验证集分布失衡:指标忽高忽低

现象:换一次随机种子重切数据,验证集 mAP 波动好几个点,调参结论完全不可信。

原因:随机划分没做类别分层,某一类在验证集里样本过少,指标方差大。

解决:固定种子(如 42)并记录在实验日志里;切完统计两边各类框数,某类验证框数低于总框数 10% 就换种子或改分层抽样。所有实验用同一份划分,才有可比性。

5.5 增强过猛抹掉类别线索:模型学不动

现象:训练 loss 降得很慢,验证 mAP 长期低位徘徊,但数据检查没发现问题。

原因:色彩增强、模糊、裁剪开得太大,把「浑水 vs 正常」依赖的颜色和纹理线索破坏了。

解决:把 HSV 增益、模糊核、裁剪比例调保守,先关掉所有增强训一轮看 baseline,再逐项加回来,每加一项看验证指标变化。增强是锦上添花,不是越多越好。

6. 进阶验证与使用习惯:把「能训」变成「可信」

数据能训起来只是及格线,真正决定这份数据集值不值得长期用的,是你能不能验证它训出来的模型可信。我一般会做两件事:一是拿一小批图做「反算校验」,把模型预测框和原始标注框叠在一张图上肉眼比对,重点看边界情况和「正常」类;二是留一个完全不参与训练和调参的 holdout 小集合,最后只跑一次,避免在验证集上反复调参导致的过拟合错觉。

反算校验的脚本思路很简单:读一张图的 YOLO txt,把归一化坐标乘回宽高,画到图上,同时把 xml 的框用另一种颜色画上去,两张框应该几乎重合。如果偏差超过几个像素,回去查转换脚本。这个动作我每次换数据集都会做,五分钟能排掉一大类坐标问题。

还有一个习惯值得养成:把类别分布、划分种子、增强配置、关键超参写进一个实验记录文件,和权重放一起。这份数据集四类框数接近但单图目标稀疏,不同增强策略对结果影响不小,没有记录的话,两周后你根本说不清哪个权重对应哪套配置。我吃过这个亏,后来每次开新实验都强制先写记录再启动训练,省下的返工时间远超那几分钟。

这份水污染检测数据集的价值在于它把「标注」这件苦活干完了,2487 张图、双格式、四类框数均衡,拿来就能进训练流程。真正决定成败的是你有没有把类别索引对齐、坐标归一化、划分可复现这几步做扎实。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进