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 改进之前,先统计数据集很有必要。它能帮助我们决定后面的技术路线:

  1. 小目标多:优先考虑 P2 检测层、SPD-Conv、小目标增强或更大输入尺寸;
  2. 类别不均衡:先补数据或做采样策略,不要马上堆复杂模块;
  3. 长条形目标多:后面可以关注大核卷积、条带池化、方向感受野模块;
  4. 标注异常多:先修标签,再谈模型结构。

这一步不直接提高指标,但能减少很多无效实验。


二、大中小目标怎么定义

目标检测中常见的大中小目标划分,可以参考 COCO 的面积思路。为了方便和 YOLO 训练输入尺寸对应,本文脚本会把标注框换算到 imgsz=640 的缩放尺度下,再按面积分类:

类型 面积范围
小目标 面积小于 32 x 32
中目标 面积在 32 x 3296 x 96 之间
大目标 面积大于等于 96 x 96

这里要注意两点:

  1. 这只是训练前的数据分布分析,不等于 COCO 官方完整评测;
  2. 如果你训练时使用 imgsz=960 或其他尺寸,统计脚本里的 --imgsz 也要同步修改。

YOLO 标签格式为:

class_id x_center y_center width height

其中 x_centery_centerwidthheight 都是归一化坐标,范围通常在 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

如果你的 trainval 写的是 .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()

这段脚本的核心逻辑是:

  1. data.yaml 中读取 pathtrainvaltestnames
  2. 找到每张图片对应的 YOLO 标签文件;
  3. 把归一化框宽高换算到训练输入尺寸附近;
  4. 按小目标、中目标、大目标统计数量;
  5. 按类别输出 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 imagesbad 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、检测头、损失函数还是数据增强。这样写出来的实验路线更清楚,也更不容易变成照着模块列表盲目堆结构。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐