007-YOLO11训练前统计大中小目标-判断数据集难度
007-YOLO11 训练前统计大中小目标:判断数据集到底难在哪里
本文面向 YOLO11 自定义数据集训练和后续模型改进实验,仍沿用 Ultralytics
8.3.253作为系列版本。正式改网络之前,先统计数据集中的小目标、中目标、大目标、类别分布和异常标签,可以帮助我们判断问题到底来自数据,还是来自模型结构。
摘要
很多 YOLO 改进文章会直接从注意力机制、Neck 融合或检测头开始,但如果不先分析数据集,后面的改进很容易变成盲试。比如数据集中 70% 都是小目标,就不能只看普通 P3、P4、P5 检测层;如果某个类别只有几十个框,模型检测不好也不一定是结构问题。
本文基于 YOLO 标注格式,给出一份可直接运行的数据集统计脚本。脚本会读取 data.yaml,统计 train、val、test 中的目标数量、类别分布、大中小目标比例、平均框宽高、长宽比和异常标签情况。统计结果可以作为 YOLO11 后续改进路线的依据,例如是否需要小目标检测层、是否需要调整输入尺寸、是否优先修标注或补数据。
关键词: YOLO11、目标检测、数据集统计、小目标检测、YOLO 标签、Ultralytics、data.yaml
一、为什么训练前要先统计目标尺度
模型训练效果不好时,很多人第一反应是换模块。但在真实项目中,问题经常藏在数据里:
| 数据现象 | 可能导致的问题 |
|---|---|
| 小目标占比很高 | mAP50-95 偏低,漏检明显 |
| 某些类别样本极少 | 类别间指标差距大 |
| 标注框过松或过紧 | 定位质量不稳定 |
| 长条形目标很多 | 普通卷积特征可能不够适配 |
| 背景和目标相似 | Precision 降低,误检增加 |
| 空标签或坏标签较多 | 训练日志异常,指标波动大 |
所以在做 YOLO11 改进之前,先统计数据集很有必要。它能帮助我们决定后面的技术路线:
- 小目标多:优先考虑 P2 检测层、SPD-Conv、小目标增强或更大输入尺寸;
- 类别不均衡:先补数据或做采样策略,不要马上堆复杂模块;
- 长条形目标多:后面可以关注大核卷积、条带池化、方向感受野模块;
- 标注异常多:先修标签,再谈模型结构。
这一步不直接提高指标,但能减少很多无效实验。
二、大中小目标怎么定义
目标检测中常见的大中小目标划分,可以参考 COCO 的面积思路。为了方便和 YOLO 训练输入尺寸对应,本文脚本会把标注框换算到 imgsz=640 的缩放尺度下,再按面积分类:
| 类型 | 面积范围 |
|---|---|
| 小目标 | 面积小于 32 x 32 |
| 中目标 | 面积在 32 x 32 到 96 x 96 之间 |
| 大目标 | 面积大于等于 96 x 96 |
这里要注意两点:
- 这只是训练前的数据分布分析,不等于 COCO 官方完整评测;
- 如果你训练时使用
imgsz=960或其他尺寸,统计脚本里的--imgsz也要同步修改。
YOLO 标签格式为:
class_id x_center y_center width height
其中 x_center、y_center、width、height 都是归一化坐标,范围通常在 0 到 1 之间。脚本会结合原图宽高,把归一化宽高换算成像素尺度,再根据训练输入尺寸估算目标大小。
三、数据集目录要求
脚本默认读取 YOLO 常见目录结构:
datasets/
my_dataset/
images/
train/
val/
test/
labels/
train/
val/
test/
data.yaml
图片和标签需要同名对应:
images/train/000001.jpg
labels/train/000001.txt
data.yaml 示例:
path: G:/datasets/my_dataset
train: images/train
val: images/val
test: images/test
names:
0: class_a
1: class_b
如果你的 train 或 val 写的是 .txt 图片列表,脚本也会尽量读取列表中的图片路径。
四、安装需要的依赖
统计脚本只依赖几个常用库:
pip install pyyaml pillow pandas
如果你已经安装了 Ultralytics,大多数环境里这些库可能已经存在。缺哪个再补哪个即可。
五、完整统计脚本
新建脚本 analyze_yolo_boxes.py,内容如下:
import argparse
import csv
from collections import Counter, defaultdict
from pathlib import Path
import yaml
from PIL import Image
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
def resolve_path(value, yaml_dir, dataset_root):
path = Path(value)
if path.is_absolute():
return path
root_path = dataset_root / path
if root_path.exists():
return root_path
return yaml_dir / path
def collect_images(split_value, yaml_dir, dataset_root):
if split_value is None:
return []
if isinstance(split_value, (list, tuple)):
images = []
for item in split_value:
images.extend(collect_images(item, yaml_dir, dataset_root))
return images
split_path = resolve_path(str(split_value), yaml_dir, dataset_root)
if split_path.is_file() and split_path.suffix.lower() == ".txt":
images = []
for line in split_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
item_path = Path(line)
if not item_path.is_absolute():
item_path = split_path.parent / item_path
images.append(item_path)
return images
if split_path.is_dir():
return sorted(p for p in split_path.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES)
return []
def label_path_for_image(image_path):
parts = list(image_path.parts)
for i in range(len(parts) - 1, -1, -1):
if parts[i].lower() == "images":
parts[i] = "labels"
return Path(*parts).with_suffix(".txt")
return image_path.with_suffix(".txt")
def normalize_names(names):
if isinstance(names, dict):
return {int(k): str(v) for k, v in names.items()}
if isinstance(names, list):
return {i: str(v) for i, v in enumerate(names)}
return {}
def size_bucket(area):
if area < 32 * 32:
return "small"
if area < 96 * 96:
return "medium"
return "large"
def analyze(data_yaml, imgsz):
yaml_path = Path(data_yaml)
yaml_dir = yaml_path.parent
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
dataset_root = data.get("path", yaml_dir)
dataset_root = resolve_path(dataset_root, yaml_dir, yaml_dir)
names = normalize_names(data.get("names", {}))
total_images = 0
missing_label_images = 0
empty_label_images = 0
bad_label_lines = 0
object_count = 0
class_counter = Counter()
size_counter = Counter()
split_counter = Counter()
class_size_counter = defaultdict(Counter)
class_sums = defaultdict(lambda: {"w": 0.0, "h": 0.0, "area": 0.0, "aspect": 0.0})
for split in ["train", "val", "test"]:
images = collect_images(data.get(split), yaml_dir, dataset_root)
split_counter[f"{split}_images"] = len(images)
total_images += len(images)
for image_path in images:
label_path = label_path_for_image(image_path)
if not label_path.exists():
missing_label_images += 1
continue
lines = [line.strip() for line in label_path.read_text(encoding="utf-8").splitlines() if line.strip()]
if not lines:
empty_label_images += 1
continue
try:
width, height = Image.open(image_path).size
except Exception:
bad_label_lines += len(lines)
continue
scale = min(imgsz / width, imgsz / height)
for line in lines:
parts = line.split()
if len(parts) < 5:
bad_label_lines += 1
continue
try:
cls_id = int(float(parts[0]))
box_w = float(parts[3]) * width * scale
box_h = float(parts[4]) * height * scale
except ValueError:
bad_label_lines += 1
continue
if box_w <= 0 or box_h <= 0:
bad_label_lines += 1
continue
area = box_w * box_h
aspect = max(box_w / box_h, box_h / box_w)
bucket = size_bucket(area)
object_count += 1
split_counter[f"{split}_objects"] += 1
class_counter[cls_id] += 1
size_counter[bucket] += 1
class_size_counter[cls_id][bucket] += 1
class_sums[cls_id]["w"] += box_w
class_sums[cls_id]["h"] += box_h
class_sums[cls_id]["area"] += area
class_sums[cls_id]["aspect"] += aspect
print("images:", total_images)
print("objects:", object_count)
print("missing label images:", missing_label_images)
print("empty label images:", empty_label_images)
print("bad label lines:", bad_label_lines)
print("split summary:", dict(split_counter))
print("size summary:", dict(size_counter))
output_csv = yaml_path.with_name("box_size_summary.csv")
with output_csv.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow([
"class_id",
"class_name",
"count",
"small",
"medium",
"large",
"avg_w_at_imgsz",
"avg_h_at_imgsz",
"avg_area_at_imgsz",
"avg_aspect",
])
for cls_id in sorted(class_counter):
count = class_counter[cls_id]
sums = class_sums[cls_id]
writer.writerow([
cls_id,
names.get(cls_id, f"class_{cls_id}"),
count,
class_size_counter[cls_id]["small"],
class_size_counter[cls_id]["medium"],
class_size_counter[cls_id]["large"],
round(sums["w"] / count, 2),
round(sums["h"] / count, 2),
round(sums["area"] / count, 2),
round(sums["aspect"] / count, 2),
])
print("csv saved:", output_csv)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--data", required=True, help="path to YOLO data.yaml")
parser.add_argument("--imgsz", type=int, default=640, help="training image size")
args = parser.parse_args()
analyze(args.data, args.imgsz)
if __name__ == "__main__":
main()
这段脚本的核心逻辑是:
- 从
data.yaml中读取path、train、val、test和names; - 找到每张图片对应的 YOLO 标签文件;
- 把归一化框宽高换算到训练输入尺寸附近;
- 按小目标、中目标、大目标统计数量;
- 按类别输出 CSV 汇总表。
六、运行脚本
在数据集根目录或任意工作目录下运行:
python analyze_yolo_boxes.py --data G:/datasets/my_dataset/data.yaml --imgsz 640
运行后会在 data.yaml 同级目录生成:
box_size_summary.csv
终端也会输出类似下面的信息结构:
images: 数据集图片总数
objects: 标注框总数
missing label images: 缺少标签文件的图片数
empty label images: 空标签图片数
bad label lines: 格式异常的标签行数
split summary: train、val、test 的图片和目标数量
size summary: small、medium、large 的目标数量
csv saved: 汇总表保存位置
这里的数字需要以你自己的数据集为准。文章或实验记录中不要直接使用别人的统计结果。
七、CSV 汇总表怎么看
生成的 box_size_summary.csv 中,每一行对应一个类别:
| 字段 | 含义 |
|---|---|
class_id |
类别编号 |
class_name |
类别名称 |
count |
该类别标注框总数 |
small |
该类别小目标数量 |
medium |
该类别中目标数量 |
large |
该类别大目标数量 |
avg_w_at_imgsz |
缩放到训练输入尺寸后的平均框宽 |
avg_h_at_imgsz |
缩放到训练输入尺寸后的平均框高 |
avg_area_at_imgsz |
缩放后的平均框面积 |
avg_aspect |
平均长宽比,越大说明越细长 |
如果某个类别 small 占比很高,后续模型改进时就要重点观察这个类别的召回情况。
如果某个类别 count 很低,不建议直接归因于网络结构。先补数据、修标注或合并过细类别,往往比换模块更有效。
八、根据统计结果规划改进方向
统计完成后,可以按下面思路判断:
| 统计结果 | 后续优先方向 |
|---|---|
| 小目标占比高 | 增加 P2 检测层、提高输入尺寸、尝试小目标增强 |
| 中大目标为主 | 先保持 P3、P4、P5,重点看特征融合和定位质量 |
| 类别长尾明显 | 补充少样本类别,保持验证集类别分布稳定 |
| 长宽比很大 | 关注方向感受野、条带池化、大核卷积等结构 |
| 空标签很多 | 确认是否为真实负样本,避免误删 |
| 坏标签较多 | 先修复标签格式,再训练模型 |
这一步的价值在于避免盲目改网络。例如数据里小目标并不多,就没必要第一时间上 P2 检测层;如果类别极度不均衡,复杂注意力模块也不一定能解决少样本问题。
九、统计结果如何写进实验台账
建议在 baseline 记录表旁边增加一张数据集分析表:
| 项目 | 记录方式 |
|---|---|
| 图片总数 | 按脚本输出填写 |
| 标注框总数 | 按脚本输出填写 |
| 小目标比例 | small / objects |
| 中目标比例 | medium / objects |
| 大目标比例 | large / objects |
| 最少样本类别 | 根据 CSV 中 count 最小的类别填写 |
| 平均长宽比最高类别 | 根据 CSV 中 avg_aspect 最大的类别填写 |
| 异常标签数量 | missing label images、bad label lines |
后续文章中如果要解释为什么尝试某个小目标模块,可以引用这张分析表。例如:
从数据统计可以看到,本数据集中小目标占比较高,因此后续实验会优先关注 P2 检测层、SPD-Conv 和小目标特征增强方法。
这种写法比直接说“该模块适合小目标”更有说服力,因为它和自己的数据集联系起来了。
十、常见问题
1. 空标签图片要不要删除
不一定。空标签图片如果是真实背景图,可以作为负样本保留;如果是漏标,就必须补标。关键是区分“没有目标”和“忘记标注”。
2. 小目标占比高就一定要加 P2 吗
不一定。P2 会增加计算量和显存占用。建议先建立 YOLO11n baseline,再根据小目标类别的 Recall 和 mAP 表现决定是否增加 P2。
3. 统计脚本里的 small、medium、large 是最终评测指标吗
不是。这里是训练前的数据分布统计,用来帮助判断数据难点。正式 mAP 指标仍然以 Ultralytics 的验证结果为准。
4. 标签文件里出现超过 1 的坐标怎么办
YOLO 归一化坐标通常应在 0 到 1 附近。如果出现明显超过范围的值,优先检查标注格式是否从 VOC、COCO 或 LabelMe 转换错误。
5. 类别编号和 names 对不上怎么办
需要先修正 data.yaml 或标签类别编号。类别编号错了,模型会学习到错误映射,后面换任何模块都很难得到可靠结果。
十一、总结
本文完成了 YOLO11 训练前的数据集目标尺度统计流程。通过读取 YOLO 标签和 data.yaml,我们可以得到图片数量、目标数量、类别分布、小中大目标比例、平均框宽高、长宽比和异常标签情况。
这一步不是为了替代训练,而是为了让训练和改进更有方向。数据集中到底是小目标多、类别不均衡、长条目标多,还是标签质量有问题,应该先用统计结果说话。
后续继续做 YOLO11 改进时,就可以把本文的统计结果和 baseline 指标结合起来:先知道数据难在哪里,再决定该改 Backbone、Neck、检测头、损失函数还是数据增强。这样写出来的实验路线更清楚,也更不容易变成照着模块列表盲目堆结构。
更多推荐




所有评论(0)