简介这是一份面向深度学习目标检测任务的黑夜港口船只检测数据集采用VOC标注格式的xml文件专注解决低光照环境下船只目标的识别与定位问题适合用于目标检测模型训练与算法验证。压缩包内共2000个文件其中1999个为xml标注文件另附1个可视化py脚本资源包整体81.13MB无需额外处理即可直接使用。数据按目录划分为训练集与测试集训练集包含4484张图片及对应4484个xml标注测试集包含1120张图片及对应1120个xml标注并附带类别json字典文件。配套可视化脚本支持随机传入图片自动绘制边界框并保存方便快速检查标注效果整体目录结构清晰可直接用于主流目标检测模型的训练与评估。目前已有359人学习下载适合作为夜间港口监控、海事感知等场景的算法研发基础数据。1. 黑夜港口船只目标检测数据集一条被低估的“单一类别”实战路径做目标检测的工程师大多经历过这样的尴尬白天效果不错的模型一到了夜间港口、江面、近海场景mAP直接腰斩。原因不是网络结构不够新而是训练数据里缺少“黑夜港口船只”这种带光照噪声、水面反光、船身轮廓模糊的样本。这套数据集只标注了一个类别——船但用VOC格式的xml文件保存了每一处目标的坐标和类别正好用来补上夜间场景这一环。它的价值不在“类别多”而在“场景专”你不需要从零收集夜间船舶图像也不用自己转格式拿到手就能接入YOLO、SSD、Faster R-CNN这些主流检测框架做微调或从零训练。适合的目标对象很明确刚接触目标检测、想用一份“干净又不单调”的数据集跑通全流程的初学者以及要做港口监控、内河航运、海事搜救等夜间感知项目的工程人员。下面我按自己处理这类数据集的习惯从标注格式、训练配置到踩坑排查完整拆一遍。2. VOC标注格式拆解xml文件里的每个字段都是训练的前置条件2.1 xml标注的标准结构folder、filename、source、size与objectVOC格式全称是PASCAL VOC虽然现在很多数据集改用COCO或YOLO格式但VOC的xml仍然是最容易手写、最容易校验的标注格式。这套“黑夜港口船只数据集”既然以VOC xml提供你要做的第一件事不是急着训练而是先打开一个xml文件看清楚里面有哪些字段以及这些字段会被训练框架的哪个环节消费。一个典型的船只标注xml长这样annotation foldernight_port_ship/folder filenamenight_001.jpg/filename source databaseNightPortShipDataset/database /source size width1920/width height1080/height depth3/depth /size segmented0/segmented object nameship/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin342/xmin ymin287/ymin xmax891/xmax ymax564/ymax /bndbox /object /annotation这里最核心的是size和object。size里的width、height、depth决定图像原始尺寸后面所有标注坐标都基于这个尺寸。object里的bndbox给出四个整数左上角x、y和右下角x、y。注意VOC的坐标是绝对像素值不像YOLO那样归一化到0~1。name是类别名这个数据集只有ship一个值但你不要在脚本里写死类别名最好从xml里动态读取避免以后换数据集时重新改代码。值得留意的是difficult和truncated两个字段。difficult1表示该目标很难辨识很多框架默认忽略这些框truncated1表示目标超出图像边界。如果这份黑夜港口数据集的制作方没有处理这两个字段而你直接拿全部object去训练可能会导致模型学到一些截断严重的船体特征反而干扰完整船只的检测。我一般会先统计一下这两个字段的分布如果truncated1的样本占比超过5%建议在数据加载时直接滤掉。2.2 用Python解析VOC xml并统计类别与目标数量拿到几百甚至上千个xml文件后人眼一个个看肯定不现实。第一步写个小脚本把每个xml里的文件名、图像尺寸、目标个数、类别名、是否被截断全部抽出来落到一个CSV里这样你能快速判断这份数据集“干不干净”。我用的是标准库xml.etree.ElementTree和pandas不需要额外装重型依赖。import xml.etree.ElementTree as ET import pandas as pd import glob, os xml_list glob.glob(annotations/*.xml) records [] for xml_path in xml_list: tree ET.parse(xml_path) root tree.getroot() filename root.findtext(filename) width int(root.findtext(size/width)) height int(root.findtext(size/height)) for obj in root.findall(object): name obj.findtext(name) truncated int(obj.findtext(truncated)) difficult int(obj.findtext(difficult)) xmin float(obj.findtext(bndbox/xmin)) ymin float(obj.findtext(bndbox/ymin)) xmax float(obj.findtext(bndbox/xmax)) ymax float(obj.findtext(bndbox/ymax)) records.append({ xml: os.path.basename(xml_path), filename: filename, width: width, height: height, class: name, truncated: truncated, difficult: difficult, xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax }) df pd.DataFrame(records) print(df[class].value_counts()) print(目标总数:, len(df)) print(平均每张图目标数:, df.groupby(filename).size().mean()) print(截断目标占比:, (df[truncated] 1).mean())这段代码的逻辑很直接遍历所有xmlroot.findtext(size/width)按路径取子节点文本root.findall(object)拿到所有目标块。我们不是只统计数量更关键的是看truncated占比和平均每图目标数。如果平均每张图只有0.5个目标说明很多图是纯背景训练时负样本比例失衡需要调整采样策略如果目标数很多但图像尺寸很小可能是密集小船场景对anchor尺寸和NMS阈值都提出不同要求。跑完这个脚本你对数据集的“体型”心里有数再去配参数就不会瞎猜。2.3 从VOC到YOLO两种格式的坐标系换算与脚本实现虽然我们最终可以写一个自定义Dataset类直接读xml但YOLO系列工具链包括YOLOv5、YOLOv8、Ultralytics默认要求txt标注格式一行一个目标格式是class x_center y_center width height且所有值都归一化到0~1。所以绝大多数情况下你需要先做一次VOC到YOLO的转换。这个转换本身不难坑全在坐标换算和路径处理上。import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, out_dir, class_names): tree ET.parse(xml_path) root tree.getroot() width int(root.findtext(size/width)) height int(root.findtext(size/height)) filename os.path.splitext(os.path.basename(xml_path))[0] out_txt os.path.join(out_dir, filename .txt) lines [] for obj in root.findall(object): name obj.findtext(name) if name not in class_names: continue # 只保留我们关心的类别 class_id class_names.index(name) xmin float(obj.findtext(bndbox/xmin)) ymin float(obj.findtext(bndbox/ymin)) xmax float(obj.findtext(bndbox/xmax)) ymax float(obj.findtext(bndbox/ymax)) # 防止越界 xmin max(0, min(xmin, width)) xmax max(0, min(xmax, width)) ymin max(0, min(ymin, height)) ymax max(0, min(ymax, height)) x_center (xmin xmax) / 2.0 / width y_center (ymin ymax) / 2.0 / height box_width (xmax - xmin) / width box_height (ymax - ymin) / height lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {box_width:.6f} {box_height:.6f}) with open(out_txt, w) as f: f.write(\n.join(lines))这里参数说明一句话class_names是一个列表比如[ship]顺序就是类别ID映射。这个脚本里我额外做了坐标裁剪把越界的xmin/xmax强制约束在图像宽高内避免负数或超出图像的值导致训练时锚框计算异常。你可能会问VOC标注本来就是合法的为什么还要裁剪因为有些标注工具在手动标注时会手滑把点标出图像外或者图像经过缩放后旧xml没有同步更新。这类脏数据不处理训练时会出现loss突然变成NaN或边界框回归发散的情况。另外转换后别忘了检查txt没有空行以及每行的class_id是否落在合法区间。一个简单办法是转换后随机抽取几个样本用OpenCV把标注框画回图像上肉眼确认框的位置是否贴合船体。这个可视化校验步骤千万别省后面避坑章会讲一个我因为跳过它而翻车的案例。3. 用黑夜港口数据集训练YOLOv8检测模型从划分数据集到跑通训练3.1 数据集目录组织与train/val划分YOLO系列对目录结构有约定虽然Ultralytics也支持自定义路径但遵循默认约定最省事。我建议把数据集整理成下面这样night_port_ship/ ├── images/ │ ├── train/ │ │ ├── night_001.jpg │ │ └── ... │ └── val/ │ ├── night_100.jpg │ └── ... ├── labels/ │ ├── train/ │ │ ├── night_001.txt │ │ └── ... │ └── val/ │ └── night_100.txt └── data.yaml划分比例我一般用8:1:1即train占80%val占10%test占10%。但很多公开数据集只分train和valtest直接用val代替这也能接受。关键是划分时必须保证同一张图像不会同时出现在train和val里更不能出现“图像在train、对应txt在val”这种错位。划分脚本用Python写用shutil移动文件而不是复制因为图像文件往往很大复制会浪费磁盘空间。按文件名随机打乱后分配比例即可。但有一个细节黑夜港口场景中可能同一艘船出现在连续多帧里随机划分会把高度相似的相邻帧同时分进train和val造成验证指标虚高。更稳妥的做法是按视频片段或拍摄时间分组确保同一片段的帧只进一个集合。遗憾的是很多数据集没有提供视频序列ID这时候只能退而求其次按文件名前缀分组。如果你的数据集命名是night_0001.jpg这种连续编号可以按编号区间划分比如前80%做train后20%做val这样至少能避免把连续帧切散。3.2 训练配置文件data.yaml的写法与关键参数YOLOv8的data.yaml是连接数据集与模型的桥梁。这个文件里写什么直接决定训练能否启动。下面是一份适用于该数据集的配置path: /data/night_port_ship # 数据集根目录建议写绝对路径 train: images/train val: images/val test: images/test # 可选 nc: 1 names: 0: ship几个参数容易踩坑path如果写相对路径会被解析到当前工作目录新手经常因为终端工作目录不同而报错“Dataset not found”。我习惯用绝对路径。train和val的值是相对于path的路径而不是完整路径。nc是类别数这里只有1但别写0。names是类别名列表保持和转换脚本里的class_names一致顺序不能乱因为txt里的class_id就是数组下标。另外建议在yaml里加这两行# 缓存图像到内存加速训练如果是SSD盘或内存不足就设为False cache: Truecache: True会把图像预加载到RAM极大减少磁盘IO等待。如果数据集有几万张图像或者你的机器只有16G内存就改成cache: False否则内存溢出会被系统kill掉训练进程。另一个选择是cache: disk使用LMDB缓存到磁盘速度介于两者之间适合内存不够又嫌硬盘慢的场景。3.3 训练命令、常用超参与显存占用预估配置写好后启动训练的命令比想象中短。以YOLOv8为例yolo detect train \ modelyolov8n.pt \ data/data/night_port_ship/data.yaml \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ lrf0.01 \ optimizerSGD \ project/output/night_ship \ nameexp01 \ seed42参数含义逐个说modelyolov8n.pt是预训练权重n代表nano版本适合在低显存机器上微调。如果你只有6G显存nano版本配imgsz640、batch16一般能跑起来如果是8G显存可以尝试yolov8s如果显存只有4G建议把batch降到8或4同时把imgsz降到480。lr00.01是初始学习率微调时我喜欢用0.005更稳但从零训练用0.01也不错。lrf0.01是最终学习率系数表示学习率会余弦衰减到初始值的1%。optimizerSGD在目标检测里依旧能打Adam收敛快但最终mAP往往不如SGD调到位。seed42固定随机种子保证两次训练结果可比方便排查问题。显存占用有个粗略估算公式训练显存 ≈ batch × imgsz² × 3字节 × 模型系数。对于yolov8n模型系数约0.35yolov8s约0.7yolov8m约1.2。以batch16、imgsz640为例yolov8n大约需要 16 × 640×640 × 3 × 0.35 ≈ 6.8G。如果你显卡是8G这个配置已经很接近上限建议降到batch8。训练过程中还可以用nvidia-smi监控显存如果发现接近100%不用改代码直接在命令行里降batch重跑即可。损失没变但显存爆了的情况通常不是代码问题是参数组合超了硬件边界。4. 避坑黑夜港口数据集训练时的5个常见问题与排查4.1 现象训练loss不降或mAP始终为0这几乎是我被问得最多的问题。很多人拿到数据集、配好yaml、命令一跑loss从第一轮开始就在1.2左右不动或者验证集的mAP一直是0。先看训练日志里是否有警告“WARNING: imgsz[640, 640] but dataset contains image with shape...”。常见原因是图像尺寸不统一有的图是1920×1080有的是640×480而标注坐标还是基于原始尺寸的绝对像素。如果某个xml的width/height写错了比如实际图像是1280×720但xml里写的1920×1080那么归一化后的yolo坐标全部错位模型学习到的框和真实船体重合度极低。解决方法是做一次全量校验用Python读取每张真实图像的实际尺寸与xml里的size字段比对不一致就输出文件名。修正方式不是改xml而是用图像的实际尺寸重新归一化。更省事的办法是写一个数据加载时的预处理钩子在进入网络前把所有图像resize到相同尺寸同时把标注坐标按比例缩放。Ultralytics本身也做resize但它依赖你提供的坐标已经是正确的归一化值。所以问题根源还是出在xml与图像不匹配。4.2 现象标注框与图像内容明显错位我在一个夜间港口数据集上犯过这个错转换脚本写完后我只抽查了3张图感觉框大体在船上就直接开训。结果训练到第50轮时val的precision一直上不了0.6我逐张可视化才发现有大约15%的图像的标注框整体偏左上偏移量在图像宽度的5%左右。原因是这批图像是从视频流中抽帧的原始视频经过抽帧处理时有裁切但xml没有同步更新坐标原点。这类错位靠算法自动修很难因为偏移量不是固定的。我的经验是先在train和val里各随机抽20张图写一个可视化脚本把xml的框画上去人工扫一遍。如果发现偏移是系统性的比如全部向右下偏可以用OpenCV的仿射变换统一修正如果偏移是随机出现的那这批数据质量不合格宁可删掉那些错位样本也不要让模型去学习错误标注。删样本前先统计错位比例低于10%就删高于10%建议换数据集。4.3 现象红外与可见光通道混淆导致训练崩溃黑夜港口数据往往混合了可见光相机和红外热像仪拍摄的图像。可见光是3通道RGB红外是单通道灰度图或者伪彩图。如果数据集目录里同时存在这两种图像而你的预处理代码强制所有图像都走cv2.cvtColor(img, cv2.COLOR_BGR2RGB)灰度图会被转成三通道的重复灰度虽然不报错但网络学到的特征会变得奇怪。更严重的是如果某个xml标注的size写的是depth1而实际图像是单通道加载时会导致维度不匹配直接报错。解决方法是先统计数据集中所有图像的通道数。脚本里用cv2.imread后再看img.ndim如果是2就补成三通道np.stack([img]*3, axis-1)。注意别用cv2.cvtColor把灰度图转成BGR那只是把单通道复制三遍但颜色空间标签仍然是灰度部分增强库会对它再做一次通道处理导致结果混乱。最省心的方法是在数据预处理阶段统一所有图像转成RGB三通道红外图用灰度复制成三分量。这样模型虽然见不到红外单通道的原始形态但至少训练稳定。如果项目要求保留红外特性就得用带多光谱输入的网络结构那就是另一套方案了。4.4 现象类别只有1类但训练时出现“class imbalance”提示你可能会觉得只有ship一个类别不存在类别不平衡。但目标检测里的不平衡不仅指类别间还包括前景与背景的比例。黑夜港口图像往往有大面积的黑天水、码头灯光、桥墩真正带船舶目标的区域只占很小一部分。如果你的数据集里大量图像完全没有目标空标签txtYOLO会把它们当作纯负样本对loss贡献很复杂一方面模型可能学会“无脑输出背景”以降低loss另一方面如果空图比例超过30%训练很容易陷入“一开始mAP0后面慢慢爬”的窘境。我的处理办法是控制空图比例。统计每张图像的txt里有多少行目标如果空图超过20%有两种方案一是把空图从train中剔除只保留含目标的图像二是保留空图但设置sampling_ratio之类参数让空图参与训练的比例降低。Ultralytics没有直接暴露这个参数所以多数情况下我选择剔除法。但要注意验证集必须保留一部分空图否则模型在真实夜航场景中会对“没有船的帧”产生幻觉框。验证集里的空图是评估误检率的重要样本。4.5 现象做了图像增强后验证mAP反而下降黑夜图像普遍亮度低、对比度差新手第一反应是做直方图均衡化、Gamma校正、CLAHE。我在自己的项目里试过把CLAHE加进训练的数据增强管线结果val mAP掉了5个点。原因很微妙CLAHE会改变图像的局部纹理走向让船体轮廓变得更“平”同时也增强了水面波纹的噪声。训练时模型看到的是增强后的图像但验证时用的是原始图像分布偏移导致特征不匹配。如果你确实想用增强我建议把增强只加在训练侧并且使用轻度版本。Ultralytics里可以通过hsv_h、hsv_s、hsv_v调节亮度饱和度但对黑夜场景最有效的是保持原始光照分布只做随机的亮度扰动让模型见到多种暗度层次。另外mosaic增强在目标较小时很有效但如果你的船只在原图里已经很小比如目标面积小于图像面积的1%mosaic会把它们进一步缩小导致学习困难。遇到这种小而暗的目标我一般把mosaic关闭或只在最后10个epoch开启同时调大scale参数的范围上限限制缩放程度。5. 让模型在真实夜航环境中更可用验证指标与三招提升鲁棒性把训练跑通不是终点关键是验证模型在“没见过的夜航画面”上是否真能用。我常用的验证方式分三层第一层是标准mAP0.5和mAP0.5:0.95但单看数值不够第二层是可视化预测把置信度阈值调到0.25看漏检和误检到底发生在哪些图像上第三层是连续帧稳定性测试就是把一段夜航视频逐帧输入模型统计相邻帧的检测框抖动幅度——如果同一艘船在相邻帧中位置跳变超过自身宽度的20%说明模型输出不稳定落地时监控屏会一直闪。提升鲁棒性我建议优先做三件成本低收益高的事第一把训练时的imgsz提高一档。如果之前用640试试896或1024。夜间远处的小船在低分辨率下可能只有十几个像素放大到640后更是模糊成一片。提高输入尺寸能让模型看到更多纹理细节代价是显存和推理耗时增加。我测试过从640提到896小目标mAP能提升8~12%推理帧率下降约30%。如果你的部署硬件是GPU服务器这个交换是划算的。第二采用“两阶段微调”策略。不用直接拿预训练权重在黑夜数据上从头训100轮而是先冻结backbone只训练head部分20轮让检测头适配夜间的框分布然后解冻所有层用很小的学习率比如0.001再训50轮。这样能避免预训练模型在白天学到的特征被夜间图像大幅扰动后产生灾难性遗忘。我很多夜间项目都是靠这个办法把mAP稳定在预期值以上。第三在推理侧做一个简单的“时间滤波”后处理。对视频流维护一个队列对连续3帧检测框做交并比匹配保留在至少两帧中出现的框并取它们坐标的中值作为最终输出。这个技巧不需要改模型结构一行代码的循环逻辑能把闪烁帧和单帧误检滤掉一大部分。具体实现时注意类别ID必须一致且对低置信度框0.25~0.4才应用滤波器高置信度框直接输出避免引入额外延迟。作为一个做过好几个夜航项目的工程师我最后想说的是数据集是单一类别不代表任务简单。黑夜港口场景的难点集中在低照度、目标小、背景杂波强这三者叠加起来比白天多类别的检测更考验数据质量。我自己的习惯是每次拿到新数据集先花半天做格式校验和可视化再花半天跑一个nano模型的快速验证确认数据链路没问题后才启动正式训练。这套流程虽然麻烦但帮我避开了无数次的“训练到半夜发现标注错了”。希望这些操作细节和踩坑记录能帮你在自己的项目里少走几步弯路。本文还有配套的精品资源点击获取
