Ultralytics YOLO27: 加入候补名单。
Get Started
不止于检测:分割、姿态、OBB 和分类·分类、组合、出航·课程 9/10
课程中级

在一个流水线中组合多个任务

构建先检测再分类的级联流程,让每个被跟踪的目标只分类一次而不是每帧都分类,并将姿态关键点转换为逐人的时间序列。

检测之外最实用的系统通常由两个模型组成,而不是一个。检测器找出每位工人,分类器判断每顶安全帽的类型是否正确;姿态模型找出关节,跟踪器则将关节转换为时间线,以便统计重复次数。代码很短。真正会让这些系统出问题的是第二阶段数据集与第一阶段裁剪图像不匹配,以及成本会随着每帧的每个目标而增加——如果直接运行,一个包含十个人的 30 FPS 视频流每秒需要进行 300 次分类。

学习成果

运行先检测再分类脚本、每个被跟踪的人只分类一次的版本,以及记录每个人膝关节角度的姿态跟踪器;并使用检测器自己的裁剪图像来构建分类器训练数据。

快速通道
如果你已经熟悉相关内容,以下是简要版。
  1. 使用 result.orig_img[y1:y2, x1:x2] 进行检测并裁剪,然后在一次批处理调用中对所有裁剪图像进行分类。

  2. 使用检测器裁剪图像(save_crop=True)构建分类器数据集,而不是使用影棚拍摄的照片。

  3. 处理视频时,使用 persist=True 进行跟踪,并对每个 result.boxes.id 只分类一次,然后重复使用该标签。

  4. 对于姿态加跟踪,按跟踪 ID 为每个人的关键点历史建立索引。

实践操作

两种模式覆盖大多数产品需求#

两种模式都从同一帧和同一个 Ultralytics API 开始;它们的区别在于第二阶段处理的输入不同。目标跟踪的跟踪器选择、调优和流处理,请参阅使用 ByteTrack、BoT-SORT 和 TrackTrack 跟踪目标;这里我们重点介绍如何串联这些任务。

先检测,再对裁剪图像分类#

from ultralytics import YOLO

detector = YOLO("yolo26n.pt")
classifier = YOLO("yolo26n-cls.pt")  # swap in your own fine-tuned classifier

result = detector("https://ultralytics.com/images/bus.jpg", conf=0.5)[0]
crops, labels = [], []
for box in result.boxes:
    x1, y1, x2, y2 = box.xyxy[0].int().tolist()
    crops.append(result.orig_img[y1:y2, x1:x2])  # BGR numpy crop, same as the detector saw
    labels.append(result.names[int(box.cls)])

for label, crop_result in zip(labels, classifier(crops, verbose=False)):  # one batched call for all crops
    probs = crop_result.probs
    print(f"{label:6s} -> {crop_result.names[probs.top1]} ({float(probs.top1conf):.2f})")
bus    -> bullet_train (0.31)
person -> sweatshirt (0.24)
person -> abaya (0.50)
person -> loudspeaker (0.49)
person -> frilled_lizard (0.24)

这套流程是正确的:裁剪图像来自 orig_img,保持完整分辨率,并使用检测器自己的像素坐标;分类器会在一次批处理调用中处理所有裁剪图像。这里的标签故意设置得毫无意义。

第二阶段的效果取决于它与裁剪图像的匹配程度

ImageNet 分类器从未用紧密裁剪、部分被裁切的街景人物图像训练过,因此只能猜测——注意,置信度都不超过 0.50。用清晰产品照片训练的分类器接收模糊的检测器裁剪图像时,也会出现同样的问题。

使用你自己的裁剪图像构建第二阶段数据集#

获得匹配数据的可靠方法是让检测器生成这些数据:

Ultralytics YOLO26 从机场传送带视频中保存的编号行李箱裁剪图像网格,可整理到分类器文件夹中

  1. 使用 save_crop=True 在具有代表性的视频素材上运行检测器,例如 yolo predict model=best.pt source=footage/ save_crop=True。裁剪图像会保存在 runs/detect/predict/crops/<class>/ 中。
  2. 将每个类别的裁剪图像整理到细分类别文件夹中(helmet_ok/、helmet_missing/)——这正是上一课介绍的分类数据集结构。
  3. 使用这些文件夹训练分类器,并在来自不同日期或摄像头的裁剪图像上验证。

由于裁剪图像由检测器生成,因此包含真实的取景、模糊和遮挡情况——这与分类器在生产环境中看到的情况完全相同——而且在这些图像上进行微调就是常规的迁移学习。

每个跟踪 ID 只分类一次#

安全帽不会在不同帧之间发生变化,因此对每一帧中的每个框都进行分类是在浪费计算。跟踪检测结果,并且只对每个新的跟踪 ID 分类一次:

from ultralytics import YOLO

detector = YOLO("yolo26n.pt")
classifier = YOLO("yolo26n-cls.pt")  # swap in your own fine-tuned classifier
source = "https://github.com/ultralytics/assets/releases/download/v0.0.0/decelera_portrait_min.mov"

labels = {}  # track id -> (label, confidence), filled once per person
frames = boxes_seen = 0
for result in detector.track(source, classes=[0], stream=True, persist=True, verbose=False):  # people only
    frames += 1
    if result.boxes.id is None:
        continue
    for track_id, (x1, y1, x2, y2) in zip(result.boxes.id.int().tolist(), result.boxes.xyxy.int().tolist()):
        boxes_seen += 1
        if track_id not in labels:  # first sighting: classify once, then reuse
            probs = classifier(result.orig_img[y1:y2, x1:x2], verbose=False)[0].probs
            labels[track_id] = (classifier.names[probs.top1], round(float(probs.top1conf), 2))

print(f"{frames} frames, {boxes_seen} person boxes, {len(labels)} classifier calls")
print(labels)
11 frames, 33 person boxes, 3 classifier calls
{1: ('basketball', 0.51), 2: ('maillot_(tights)', 0.14), 3: ('bulletproof_vest', 0.23)}

在 11 帧的片段中,分类器调用次数从 33 次降到 3 次;在长视频流中,随着每个人在画面中停留的时间增加,节省的计算量也会不断增加。标签仍然是 ImageNet 的猜测结果——换成你微调后的分类器,就能得到每个人对应的分类结果。

判断规则

如果目标在视野内时属性不会改变(头盔类型、车辆品牌、产品 SKU),则每条轨迹分类一次。如果属性可能改变(门打开、手举起),则每 N 帧重新分类一次。如果目标刚出现时通常较小且模糊,则改为在轨迹框最大的一帧进行分类。

姿态加跟踪#

Ultralytics YOLO 姿态估计示例:统计俯卧撑次数,画面中显示人体骨架、110 度肘关节角度和次数计数器

跟踪模式会为每个人分配一个持久的 result.boxes.id,因此关键点可以构成时间序列。这个脚本会记录同一段短视频中每个人左膝的角度:

from collections import defaultdict

import numpy as np

from ultralytics import YOLO

def angle(a, b, c):
    """Angle at b, in degrees, formed by points a-b-c."""
    ba, bc = a - b, c - b
    return np.degrees(np.arccos(np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc))))

model = YOLO("yolo26n-pose.pt")
source = "https://github.com/ultralytics/assets/releases/download/v0.0.0/decelera_portrait_min.mov"
HIP, KNEE, ANKLE = 11, 13, 15  # left leg, COCO keypoint order
knee_angles = defaultdict(list)  # track id -> knee angle per frame

for result in model.track(source, stream=True, persist=True, verbose=False):
    if result.boxes.id is None:  # no confirmed tracks in this frame
        continue
    kpts = result.keypoints.cpu().numpy()
    for track_id, xy, conf in zip(result.boxes.id.int().tolist(), kpts.xy, kpts.conf):
        if conf[[HIP, KNEE, ANKLE]].min() > 0.5:  # skip occluded legs
            knee_angles[track_id].append(angle(xy[HIP], xy[KNEE], xy[ANKLE]))

for track_id, angles in sorted(knee_angles.items()):
    print(f"id {track_id}: {len(angles)} frames, left knee {min(angles):.0f}-{max(angles):.0f} deg")
id 1: 11 frames, left knee 170-176 deg
id 2: 11 frames, left knee 96-118 deg
id 3: 11 frames, left knee 177-180 deg

ID 2 的弯曲膝盖与片段中坐着的人相符;另外两个人站立,腿部伸直,角度接近 180°。接下来,可以根据每个 ID 的角度序列检测阈值交叉来统计重复次数——角度低于一个阈值时向下,然后高于另一个阈值时再向上——这正是锻炼监控解决方案为你提供的功能。

为这条处理链规划计算预算#

成本驱动因素先检测再分类姿态加跟踪
随什么变化每帧的目标数帧数
主要调节手段批量裁剪;每个跟踪 ID 只分类一次并缓存运动缓慢时,降低 imgsz 或 vid_stride
故障模式分类器看到的裁剪图与训练数据不同ID 切换会将同一个人的历史记录拆成两段
ID 切换会破坏历史记录

症状:计数器在一组动作中途重置,或同一个人出现两个 ID。原因:跟踪器丢失了该人(被遮挡、快速转身),随后分配了新 ID。解决方法:按照生产课程中的说明调整跟踪器,并让每 ID 逻辑能够容忍短暂间断——缓存的分类器标签会为新 ID 重新计算。

试试看

在检测器调用中添加 save_crop=True,运行 two_stage.py,并确认裁剪图出现在按类别划分的文件夹中。运行 classify_per_track.py,比较分类器调用次数与人物框数量。然后扩展 pose_track.py,按跟踪 ID 打印膝盖角度低于 120° 的帧数。

完成条件
满足以下所有条件,即表示你已完成本课程。
  • crops/<class>/ 下已生成裁剪图,而且你能说明如何将裁剪图分类放入分类器文件夹。

  • 你能说出在示例视频片段中,按跟踪 ID 缓存减少了多少次分类器调用。

  • pose_track.py 会为每个跟踪 ID 打印一行,其中包含帧数和角度低于 120° 的帧数。

显示解答

将 save_crop=True 添加到检测器调用中:

result = detector("https://ultralytics.com/images/bus.jpg", conf=0.5, save_crop=True)[0]

裁剪图出现在 runs/detect/predict*/crops/person/ 和 crops/bus/ 下(每次预测运行都会递增运行文件夹名称)。按跟踪 ID 缓存后,33 个人物框只触发了 3 次分类器调用;在 11 帧的视频片段中,减少了 30 次调用。

要统计膝盖角度,请替换 pose_track.py 的最后一个循环:

for track_id, angles in sorted(knee_angles.items()):
    bent = sum(a < 120 for a in angles)
    print(f"id {track_id}: {len(angles)} frames, {bent} with knee below 120 deg")
id 1: 11 frames, 0 with knee below 120 deg
id 2: 11 frames, 11 with knee below 120 deg
id 3: 11 frames, 0 with knee below 120 deg

坐着的人(ID 2)在全部 11 帧中的角度都低于 120°;ID 1 和 ID 3 则从未低于 120°。

接下来做什么

最后,我们用恰当的指标评估每项任务,并导出每个模型以供部署。

登录以保存学习进度