
简介面向环境监测、计算机视觉与深度学习方向的研究者与开发者这份水污染检测数据集以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, encodingutf-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 2487VOC 侧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_okTrue) # 类别名 - 索引顺序以 classes.txt 为准 with open(os.path.join(ROOT, labels, classes.txt), r, encodingutf-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 够用显存紧张可降到 512batch162487 张图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 张图、双格式、四类框数均衡拿来就能进训练流程。真正决定成败的是你有没有把类别索引对齐、坐标归一化、划分可复现这几步做扎实。希望帮到你。本文还有配套的精品资源点击获取