Объединение задач в один конвейер
Создай каскад «детекция, затем классификация», классифицируй каждый отслеживаемый объект один раз, а не в каждом кадре, и преобразуй ключевые точки позы в временные ряды для каждого человека.
В самых полезных системах, построенных не только на детекции, обычно работают две модели, а не одна. Детектор находит каждого работника, а классификатор определяет, подходит ли тип каски; модель позы находит суставы, а трекер превращает их в временную шкалу, по которой можно считать повторения. Код короткий. Такие системы ломаются из-за того, что данные для второго этапа не соответствуют кропам первого этапа, а также из-за вычислительных затрат, растущих с каждым объектом в каждом кадре: при наивном подходе поток 30 FPS с десятью людьми требует 300 классификаций в секунду.
Практические занятия
Два сценария, подходящие для большинства продуктов#
Оба сценария используют один и тот же кадр и один и тот же API Ultralytics; различие в том, что именно обрабатывает второй этап. Выбор и настройка трекера, а также обработка потоков для трекинга объектов рассматриваются в материале «Трекинг объектов с 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. То же происходит с классификатором, обученным на чётких фотографиях товаров, если подать ему размытые кропы детектора.
Создай датасет для второго этапа из собственных кропов#
Надёжный способ получить подходящие данные — поручить их создание детектору:

- Запусти детектор на репрезентативном видео с помощью
save_crop=True, напримерyolo predict model=best.pt source=footage/ save_crop=True. Кропы появятся вruns/detect/predict/crops/<class>/. - Распредели кропы каждого класса по папкам нужных подклассов (
helmet_ok/,helmet_missing/) — это уже структура датасета для классификации из предыдущего урока. - Обучи классификатор на этих папках и проверь его на кропах, полученных в другой день или с другой камеры.
Поскольку кропы создал детектор, в них сохранены реальные границы кадрирования, размытие и перекрытия — именно то, что увидит классификатор в рабочей системе; а дообучение на таких кропах — это обычное переносное обучение.
Классифицируй один раз для каждого 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)}Три вызова классификатора вместо 33 на клипе из 11 кадров; на длинном потоке экономия растёт с каждым кадром, в котором человек остаётся в поле зрения. Метки пока представляют собой догадки ImageNet — замени классификатор на дообученный, и получишь ответ для каждого человека.
Классифицируй объект один раз на трек, если его атрибут не может измениться, пока объект в кадре (тип шлема, марка автомобиля, артикул товара). Если атрибут может меняться (открытая дверь, поднятая рука), выполняй классификацию каждые N кадров. Если при первом появлении объект обычно маленький и размытый, вместо этого классифицируй его на кадре, где рамка трека наибольшая.
Поза и трекинг#

Режим трекинга присваивает каждому человеку постоянный 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.
Показать решение
Добавь 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/ (имя папки запуска увеличивается при каждом запуске предсказания). Кэширование для каждого трека позволило выполнить 3 вызова классификатора для 33 рамок людей, сэкономив 30 вызовов на клипе из 11 кадров.
Чтобы подсчитать угол колена, замени последний цикл в 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 угол меньше 120° во всех 11 кадрах; у объектов с ID 1 и 3 такого не бывает.