☰
夜间行人检测:5000张数据+三种标签格式+YOLO11跨平台训练
2026/9/30 14:07:37 网站建设 项目流程

简介:面向夜间监控场景的行人检测需求,这份数据集整理了5000张真实夜间、低光环境下行人图像,涵盖街景、道路、遮挡及严重遮挡等多样场景,适用于公共场所监控、智慧交通等实际项目,也可作为通用行人检测数据集的夜间场景补充。数据采用LabelImg标注,质量较高,同时提供VOC、COCO、YOLO三种格式标签,可直接用于YOLO等主流算法训练。配套的YOLO11一键训练脚本支持GPU、CPU、Mac多平台运行,并附有博主训练结果日志供参考,适合监控安防方向算法工程师与研究人员快速上手。资源包内共1个PDF文件,大小约6.07MB,包含数据集详细介绍、标注截图及百度网盘获取方式,实际图片数据需按文档指引下载。文档中还附有数据缩略图与LabelImg标注实例,便于在获取完整数据前了解画质与标注样式。已有611人浏览学习,作为夜间行人检测的基础数据与训练参考,实用价值突出。

1. 夜间行人检测,卡住的从来不是模型而是数据

夜间行人目标检测是安防、车载和边缘设备里最常被问到的需求之一,白天跑得好好的模型,一入夜就漏检、误检一起上。问题大多不在模型结构,而在数据:夜间图像亮度低、噪声大,行人常常只有一小块模糊轮廓,公开数据集里又很少有专门针对夜间的行人标注。这套方案的做法是直接把数据补齐——5000张夜间行人图,每张都带VOC/COCO/YOLO三种格式的标签,再配一个同时支持GPU(GPUs)/CPU/Mac三平台的YOLO11一键训练脚本,让拿到手的人不用折腾格式转换和环境配置,直接就能把训练跑起来。适合正在做夜间行人检测、被数据预处理和跨平台训练环境反复拖住的研究者和工程师。

如果你已经在用YOLO11或其他检测模型,手头缺的不是模型代码而是夜间场景的标注数据,那么这套组合解决的是从数据到训练的最后一公里。接下来我会按数据检验、格式转换、脚本适配、踩坑排查这个顺序,把每个环节的落地细节讲清楚。

2. 夜间行人检测为什么难:先做数据体检再谈训练

2.1 夜间图像的三个典型特征:低照度、暗部噪声、小目标占比高

夜间行人和白天行人最大的区别不是“人变了”,而是“图变了”。一张夜间监控图里,行人区域的平均灰度值往往比白天低40%以上,暗部噪声在增强对比度后会被同步放大,同时行人轮廓的边缘锐度明显下降。这些特征直接决定了训练策略:不能拿白天数据集硬凑,也不能简单套用白天的增强参数。

拿到5000张夜间行人图之后,第一件事不是开训练,而是做数据体检。我一般会先统计整批图像的亮度分布和边界框尺寸分布,这两个指标直接决定增强策略和学习率上限。用Python跑一个快速统计脚本,输出每张图的灰度均值和每个标注框的像素面积。

import cv2 import os from glob import glob img_dir = "images/train" boxes = [] # 假设已有解析好的bbox列表: (x1, y1, x2, y2) brightness_list = [] for img_path in glob(os.path.join(img_dir, "*.jpg")): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) brightness_list.append(img.mean()) print(f"平均亮度: {sum(brightness_list)/len(brightness_list):.2f}") print(f"最暗图像亮度: {min(brightness_list):.2f}") print(f"最亮图像亮度: {max(brightness_list):.2f}") # 统计bbox面积分布 areas = [(x2-x1)*(y2-y1) for x1, y1, x2, y2 in boxes] sorted_areas = sorted(areas) print(f"bbox面积中位数: {sorted_areas[len(sorted_areas)//2]:.0f}px") print(f"最小10%的bbox面积: {sorted_areas[len(sorted_areas)//10]:.0f}px")

这段代码的逻辑很简单:用OpenCV读灰度图算均值得到亮度分布,再把标注框面积排序看小目标占比。参数说明里有两个关注点,一个是亮度均值低于80的图如果超过三成,就一定要在训练前加亮度增强;另一个是bbox面积中位数如果低于32×32,说明小目标居多,训练时的imgsz建议设为640而不是1280——分辨率设得太高反而会让小目标在缩放采样中丢失。

2.2 5000张图的规模够不够:按场景分布而非只看总数

很多人一看到5000张第一反应是“够吗”,这个问题的答案取决于场景分布而不是总数。5000张如果全是同一个路口的同一视角,训练效果还不如按场景分层的2000张。夜间行人检测的常见分布是城市道路、小区出入口、园区周界、地下停车场四个场景,每个场景的数量要相对均衡。

拿到数据集后,我会按文件名前缀或目录结构把图像按场景分组,确认每个场景至少有800到1000张。如果某个场景数量特别少,考虑用另一批数据做fine-tuning而不是把全部数据混在一起训。评估指标也要按场景分开算,综合mAP高但停车场场景mAP低的情况很常见,这种时候单纯加数据不如检查停车场场景的标注质量。

标注质量检查同样不能省。夜间图的标注难点在于低照度下行人边界不清晰,标注框容易偏大或偏小。我常用一个简单的检查方法:把标注框画回图上生成预览图,肉眼抽查100张,重点看遮挡严重的区域和远距离小目标区域。抽查时发现框偏大还是偏小,要记录比例,批量修正后用修正后的标签再训练,这比模型训完再回头查数据省时间得多。

提示:夜间行人标注最容易出问题的不是“有行人没标”,而是“把路灯阴影、灯箱广告里的人形轮廓标成了行人”。这一类误标注会让模型在误检指标上很难看,清理时优先处理。

2.3 标签结构核对:VOC/COCO/YOLO三类格式的关键字段差异

标题里提到5000张图都带三种格式标签,这意味着数据组织上有多个目录。训练前要核对的是三类格式的字段完整性,而不是直接信任文件存在。

VOC格式是XML文件,每张图对应一个XML。关键字段是<width>和<height>必须和实际图片尺寸一致,<bndbox>的四点坐标必须是整数。COCO格式是单个JSON文件,关键字段是images数组里的id与annotations里的image_id能对上,categories里的id从1开始且连续。YOLO格式是TXT文件,每行一个目标,格式是class_id x_center y_center width height,注意center和width、height都是归一化到0到1之间的浮点数。

最常翻车的是VOC和YOLO之间的坐标换算。VOC给的是左上角(xmin, ymin)和右下角(xmax, ymax)的绝对像素值,YOLO要的是中心点坐标和宽高的归一化值。换算公式是x_center = (xmin + xmax) / 2 / width,y_center = (ymin + ymax) / 2 / height,w = (xmax - xmin) / width,h = (ymax - ymin) / height。如果图片在预处理阶段被resize过,这些归一化值必须基于resize后的尺寸重新算,不然边界框会整体偏移。

import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, img_width, img_height): tree = ET.parse(xml_path) root = tree.getroot() yolo_lines = [] for obj in root.findall("object"): cls = obj.find("name").text class_id = class_map[cls] # 需要预先定义类别映射 box = obj.find("bndbox") xmin = int(float(box.find("xmin").text)) ymin = int(float(box.find("ymin").text)) xmax = int(float(box.find("xmax").text)) ymax = int(float(box.find("ymax").text)) # 越界截断:标注框超出图像边界时必须裁剪,否则训练时loss计算会出NaN xmin = max(0, min(xmin, img_width - 1)) xmax = max(0, min(xmax, img_width - 1)) ymin = max(0, min(ymin, img_height - 1)) ymax = max(0, min(ymax, img_height - 1)) x_center = ((xmin + xmax) / 2) / img_width y_center = ((ymin + ymax) / 2) / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height yolo_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") return yolo_lines

这段脚本是VOC转YOLO的核心逻辑,其中越界截断是很多人容易漏掉的步骤:夜间图像的标注在低照度下容易出现框的一端超出图像边缘,这类标签不处理直接训练,轻则mAP下降,重则训练过程中loss变成NaN。class_map是你自己的类别映射表,必须和训练配置里的names顺序完全一致,YOLO训练时读取的是数字ID而不是类别名。

3. 把VOC/COCO/YOLO三种格式对齐:转换脚本与边界坑

3.1 三格式互转的最短实现路径:以VOC为主基准

三种格式里,VOC的XML结构最直观,字段语义最接近人类阅读习惯,我通常把它作为转换基准。做法是先把COCO和YOLO都转成VOC,需要什么格式再从VOC生成,这样只需要写两套转换逻辑,比维护六套互转脚本省心得多。

COCO转VOC时,注意JSON里的segmentation字段在行人检测里基本用不到,只需要读bbox字段,它的格式是[x, y, width, height],和VOC的xmin/ymin/xmax/ymax差一个换算。YOLO转VOC时,要做的是把归一化坐标乘以图像宽高还原成像素值,再换算成左上角右下角格式。两类转换的共同点是都要查一下image_id对应的图片实际尺寸,不能凭感觉猜。

import json from PIL import Image def coco_to_voc(coco_json_path, image_dir, output_xml_dir): with open(coco_json_path, "r") as f: coco = json.load(f) # 构建 image_id -> 文件名 的映射 id_to_file = {img["id"]: img["file_name"] for img in coco["images"]} # 构建 category_id -> 类名 的映射 cat_id_to_name = {cat["id"]: cat["name"] for cat in coco["categories"]} # 按 image_id 分组 bbox 标注 anns_by_image = {} for ann in coco["annotations"]: img_id = ann["image_id"] if img_id not in anns_by_image: anns_by_image[img_id] = [] anns_by_image[img_id].append(ann) for img_id, anns in anns_by_image.items(): file_name = id_to_file[img_id] img_path = os.path.join(image_dir, file_name) img = Image.open(img_path) w, h = img.size xml_root = ET.Element("annotation") ET.SubElement(xml_root, "filename").text = file_name size_elem = ET.SubElement(xml_root, "size") ET.SubElement(size_elem, "width").text = str(w) ET.SubElement(size_elem, "height").text = str(h) for ann in anns: x, y, bw, bh = ann["bbox"] # COCO的bbox是(x, y, w, h) obj_elem = ET.SubElement(xml_root, "object") ET.SubElement(obj_elem, "name").text = cat_id_to_name[ann["category_id"]] bndbox = ET.SubElement(obj_elem, "bndbox") ET.SubElement(bndbox, "xmin").text = str(int(x)) ET.SubElement(bndbox, "ymin").text = str(int(y)) ET.SubElement(bndbox, "xmax").text = str(int(x + bw)) ET.SubElement(bndbox, "ymax").text = str(int(y + bh)) xml_path = os.path.join(output_xml_dir, os.path.splitext(file_name)[0] + ".xml") tree = ET.ElementTree(xml_root) tree.write(xml_path, encoding="utf-8", xml_declaration=True)

这段代码的逻辑是遍历COCO的JSON结构,把annotations按image_id分组,每张图生成一个XML文件。x + bw和y + bh是COCO转VOC唯一的数学变换,其他都是字段映射。参数说明里有一个容易踩的细节:PIL.Image.open拿到的是图像实际尺寸,但某些数据集里coco["images"]里的width和height字段可能与实际图片不一致,要以实际图片为准,否则标注偏移几个像素在夜间小目标上会被放大成明显错位。

3.2 转换后必须做的三步校验:文件数、坐标范围、类别映射

转换脚本跑完之后,不能直接开训练,先做三步校验。第一步是文件数对比:VOC的XML数量、COCO的images数量、YOLO的TXT数量必须一致,且和图片文件数量一致。不一致的话多半是某张图没有标注文件,或者是转换脚本里漏了空标注图的处理。

第二步是坐标范围校验:所有YOLO的归一化坐标必须在0到1之间,VOC的像素坐标必须在图像尺寸范围内。这个可以用脚本批量检查,发现越界就按上一节里的方法做截断。第三步是类别映射校验:三套标签里的类名或类别ID必须统一,比如VOC里叫"pedestrian",COCO里叫"person",YOLO里class_id对应到的是同一个类,这个不一致会让训练时的类别数量统计错乱,而且报错信息往往不直观。

def validate_yolo_labels(txt_dir, img_dir, class_count): import numpy as np errors = [] for txt_path in sorted(glob(os.path.join(txt_dir, "*.txt"))): img_path = os.path.join(img_dir, os.path.splitext(os.path.basename(txt_path))[0] + ".jpg") if not os.path.exists(img_path): errors.append(f"缺失图片: {img_path}") continue with open(txt_path, "r") as f: lines = f.read().strip().split("\n") for line in lines: parts = line.split() if len(parts) != 5: errors.append(f"字段数错误: {txt_path}: {line}") continue cls_id = int(parts[0]) coords = [float(x) for x in parts[1:]] if cls_id < 0 or cls_id >= class_count: errors.append(f"类别ID越界: {txt_path}: {cls_id}") if any(c < 0 or c > 1 for c in coords): errors.append(f"坐标越界: {txt_path}: {line}") if errors: print(f"发现 {len(errors)} 个错误,前10个:") for e in errors[:10]: print(e) else: print("校验通过")

这个校验脚本的核心逻辑是逐行检查YOLO标注的五个字段,class_count是你训练配置里的类别总数。如果类别ID等于class_count或更大,说明转换脚本的类别映射表有遗漏。坐标越界是一个很隐蔽的问题,因为YOLO训练时不会直接报错,只会让loss曲线异常波动,最后mAP莫名其妙地低。

3.3 数据集划分:夜间场景不能随机划分

数据集的train/val划分看起来是最简单的步骤,实际上夜间行人数据在这里有一个大坑:随机划分会把同一个场景的连续帧同时分进train和val,导致val的mAP虚高。比如同一个路口连续拍了200帧,随机划分后160帧在train里、40帧在val里,val里测的是模型“见过”的场景的夜间行人,指标当然好看,但部署到新场景就露馅。

我一般按场景划分而非按图片划分:每个场景的图片数量乘以0.8取整,前80%按时间顺序进train,后20%进val。这样能保证val数据是模型没有见过的场景分布,指标更接近实际部署效果。如果有超过15%的图片是连续帧且画面内容高度相似,先按每隔N帧抽帧的方式做去重,再按场景划分。

import random from collections import defaultdict random.seed(42) # 按目录前缀识别场景,例如 "scene1_001.jpg", "scene2_001.jpg" def get_scene(filename): return filename.split("_")[0] img_files = os.listdir("images") scene_to_files = defaultdict(list) for f in img_files: scene_to_files[get_scene(f)].append(f) train_files, val_files = [], [] for scene, files in scene_to_files.items(): files.sort() # 按时间顺序排序 split_idx = int(len(files) * 0.8) train_files.extend(files[:split_idx]) val_files.extend(files[split_idx:]) # 写入train.txt和val.txt with open("train.txt", "w") as f: f.write("\n".join(train_files)) with open("val.txt", "w") as f: f.write("\n".join(val_files))

这个划分逻辑的关键是files.sort()先按时间排序再按比例切分,确保val是时间上靠后的连续片段。split_idx用0.8还是0.85取决于数据量,5000张的规模用0.8比较稳妥,val保留1000张左右的量级,评估指标的置信度更高。如果你有多个摄像头拍摄的同一场景,按摄像头ID再分一层,别让val里混入同场景不同机位的图片。

4. 用YOLO11一键训练脚本打通GPU/CPU/Mac三平台

4.1 三平台训练的环境差异与脚本设计思路

YOLO11支持的平台范围很广,Windows/Linux的NVIDIA GPU、纯CPU环境、Apple Silicon的Mac都能跑,但三个平台的底层依赖完全不同。GPU环境需要CUDA、cuDNN和PyTorch的CUDA版本配套;CPU环境不需要CUDA但推理速度慢;Mac环境走的是MPS后端,PyTorch从1.12开始支持,但并非所有算子都有MPS实现。

一个脚本同时适配三平台,核心思路是自动检测环境而不是让用户手动配置。脚本启动时先读取torch.cuda.is_available()判断GPU是否可用,再判断torch.backends.mps.is_available()判断Mac的MPS是否可用,最后默认落入CPU。这三层判断不需要用户干预,训练参数里的device字段会自动设为cuda:0、mps或cpu。

import torch def detect_device(): if torch.cuda.is_available(): device = "cuda:0" print(f"使用GPU: {torch.cuda.get_device_name(0)}") elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): device = "mps" print("使用Mac MPS加速") else: device = "cpu" print("使用CPU训练,速度会较慢") return device device = detect_device()

这段代码放在训练脚本的最前面,detect_device()返回的device字符串直接传给YOLO11的model.train()方法。注意hasattr(torch.backends, "mps")这个判断不能省,因为旧版PyTorch根本没有mps属性,直接调用is_available()会抛AttributeError。另外MPS只支持Apple Silicon芯片,Intel Mac会落入CPU分支。

4.2 一键训练脚本的核心参数讲解:epochs、batch、imgsz、patience

训练脚本里除了设备检测,最重要的就是model.train()的参数设置。YOLO11的API很统一,关键参数就那么几个,但每个都要按夜间行人场景调整,不能照搬白天目标检测的配置。

epochs我设100起步,夜间行人检测的收敛比白天慢,因为低照度下的特征学习难度更高,60轮以内往往还在剧烈波动。batch需要按显存大小调整,GPU上一般8到16,CPU和MPS上建议4到8,因为这两个平台的显存带宽有限,batch太大会直接显存溢出。imgsz是输入图像分辨率,夜间小目标多用640,如果标注框大面积在32×32以下建议试1280但要用GPU。

from ultralytics import YOLO model = YOLO("yolo11n.pt") # 或 yolo11s.pt,按显存选择 results = model.train( data="night_pedestrian.yaml", epochs=100, batch=8, imgsz=640, device=device, patience=15, save_dir="runs/night_pedestrian", optimizer="auto" )

patience=15是早停轮数,15轮内val mAP没有提升就停止训练。夜间行人数据集的val波动比白天大,patience设太小容易早停,设太大会浪费时间,15是一个比较折中的值。optimizer="auto"让YOLO11自己按batch size选优化器,省得手动调SGD还是AdamW的参数,省掉很多玄学调参的环节。

4.3 跨平台训练的预期速度与合理预期

训练速度是很多人关心的实际问题,三平台的差距很大,需要提前设好预期。一张NVIDIA RTX 4090上跑yolo11n、5000张图、640分辨率、batch 16,大约每分钟处理80到120张,100轮大概在两三个小时内完成。Apple Silicon的MPS后端速度大约是GPU的1/3到1/2,同样的配置可能要六个小时以上。CPU环境最慢,只能当验证流程用,完整训练一百轮可能要十几个小时到几十个小时。

考虑到夜间行人检测的模型推理场景往往在边缘设备上,我的习惯是先在GPU上用yolo11n跑通流程验证数据和标签没有问题,再切换到目标部署平台跑完整训练。如果你没有独立GPU,Mac用户建议至少用M1 Pro以上的芯片,CPU用户建议把imgsz降到480并把epochs降到50,先确认整个pipeline没有报错,再慢慢加量。

注意:MPS后端在PyTorch和YOLO11的某些版本上存在梯度同步问题,具体表现是训练loss在几十轮后突然变成NaN。如果遇到这个情况,先试试torch.backends.mps.sync_debug_on_error = True打开调试,或直接切换到CPU训练。这个坑在Mac上做训练的教程里经常被一笔带过,实际踩到的人不少。

5. 训练路上的五个常见坑与排查思路

5.1 标注框越界导致loss变成NaN或mAP异常低

现象:训练能正常启动,但loss在几轮后出现NaN,或者训练结束后mAP异常低,大部分类别AP接近0。

原因:YOLO格式的归一化坐标不在0到1之间,通常是转换脚本没有做越界截断,或者原始标注框本身超出了图像边界。

解决:用3.2节的校验脚本全量检查TXT标签,把越界的行单独输出,逐条看是整体偏移还是单边越界。整体偏移说明转换时图像尺寸用错了;单边越界修正对应坐标即可。修正完重新训练,不用改其他参数。

5.2 夜间图像增强过度导致误检飙升

现象:训练时用了大量亮度增强和对比度增强,训练mAP不错,但实际部署时误检很多,灯箱广告、路牌反光被识别为行人。

原因:夜间图像的亮度增强如果太激进,会把暗部噪声放大成类似人形的纹理特征,模型学到了这些噪声模式。

解决:把亮度增强的范围收窄,比如原来的亮度(-0.5, 0.5)改成亮度(-0.2, 0.2),同时加一个限制:亮度增强只作用于局部区域而不是整图。YOLO11的增强参数在model.train()里用hsv_h、hsv_s、hsv_v控制,其中hsv_v是亮度扰动,夜间场景建议设0.2左右而不是默认的0.4。

5.3 Mac MPS报算子不支持错误

现象:在Mac上跑训练脚本,报错是NotImplementedError或Operator not implemented for MPS,位置通常在数据加载或损失计算阶段。

原因:PyTorch的MPS后端还在持续完善中,YOLO11的某些算子(如特定形态的RoIAlign)在MPS上没有实现,会回退到CPU,但有少数算子直接抛异常。

解决:先升级PyTorch到最新版,MPS的支持在近几个版本有明显改善。如果仍然报错,在训练脚本里加环境变量PYTORCH_ENABLE_MPS_FALLBACK=1,让不支持的算子自动回退CPU。要注意的是这个环境变量要在import torch之前设置,否则不生效。

5.4 CPU训练时内存不足或swap频繁

现象:CPU训练启动后系统变得卡顿,训练日志里每个epoch耗时越来越长,甚至直接OOM被杀。

原因:CPU训练时的num_workers设置过高,多个进程同时加载图像导致内存峰值很大。另一个原因是batch设置过大,CPU内存被激活图占满。

解决:把num_workers降到2或4,batch降到4或2,同时把cache=True关掉。CPU训练时cache=True会把整批图像缓存到内存里,显存不吃紧但内存会爆。改成cache=False后,训练时间会增加但系统稳定性大幅提升。

5.5 训练后推理效果差但val mAP却很高

现象:val mAP有0.8以上,但拿一段没参与训练的视频去测,检测结果很差,漏检严重。

原因:数据划分时没有按场景分离,val里混入了与train高度相似的连续帧,导致val指标虚高。

解决:按3.3节的场景划分逻辑重新切分数据集,确保val里的场景完全独立。重新训练后对比新val mAP,如果比之前的虚高数据下降了0.1到0.2,说明之前的数据划分确实有问题。这个做法比任何模型优化都更有效,数据划分干净了指标才有参考价值。

6. 进阶:夜间增强策略与模型导出验证

训练完成后,最后一公里的工作是确认模型在夜间场景的真正实力。我的习惯是准备一段没有出现在train和val里的夜间视频,用model.predict()逐帧跑一遍,统计漏检帧和误检帧的比例。这样比单独看mAP更能反映实际效果。

夜间推理时的增强策略和训练时不同。训练时用亮度扰动是为了模拟不同暗度,推理时用model.predict(source=video.mp4, imgsz=640, conf=0.25, iou=0.45)就够,不需要再做图像增强。conf=0.25是置信度阈值,夜间场景阈值设太低会增加误检,设太高又容易漏检,用0.25起步然后通过视频实测调整。

模型导出有两个方向:部署到边缘设备用model.export(format="onnx"),跑推理脚本用model.export(format="torchscript")。导出后要做一件事——用同一张图测试导出前后模型的推理结果,确保mAP没有明显的精度损失。ONNX模型可以用onnxruntime验证,TorchScript模型用torch.jit.load后前向传播,对比输出的检测框坐标和置信度是否一致。

自动化验证这一步最容易被跳过,但夜间场景模型的部署失败几乎都是导出精度损失和数据分布偏移导致的。把detect脚本固定下来,输入一段夜间视频,输出每帧的检测框和置信度,再对照原始视频目测一轮,这个流程跑通了,整个方案才算真正落地。

我自己的习惯是每次换数据集来源时,都用同一段夜间视频做回归验证,保证新旧模型的mAP对比建立在同一基础之上。夜间行人检测的方向会持续有数据积累,模型和脚本只是工具,数据质量才是真正决定效果上限的部分。希望这些经验能够帮你在做夜间行人检测时少走一些弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询