File size: 7,675 Bytes
762d5e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""Person detection and tracking (YOLO11 + ByteTrack); keeps the most prominent person."""

import queue
import threading
from collections import defaultdict

import numpy as np
import torch
from tqdm import tqdm
from ultralytics import YOLO

from exmr.geometry.smoothing import moving_average_smooth
from exmr.preprocessing.video import video_lwh


class Tracker:
    """Tracks all people in a video and returns the boxes of the most prominent one.

    Args:
        checkpoint: YOLO11 detection weights (yolo11m.pt is fast, yolo11x.pt more accurate)
    """

    def __init__(self, checkpoint):
        self.yolo = YOLO(checkpoint)
        # decode the video in a background thread while the detector runs
        self.yolo.add_callback("on_predict_start", _start_video_prefetch)
        self.yolo.add_callback("on_predict_end", _close_video_prefetch)

    def track(self, video_path):
        """Per frame, a list of {"id": track id, "bbx_xyxy": (4,) array} detections."""
        results = self.yolo.track(
            video_path,
            device="cuda",
            conf=0.5,
            classes=0,  # person
            verbose=False,
            stream=True,
            batch=8,
            half=True,
            persist=False,
            # ByteTrack instead of the default BoT-SORT, whose global motion compensation runs
            # on the CPU and dominates this stage, while we only need the main person's boxes
            tracker="bytetrack.yaml",
        )
        # skip Ultralytics' warm-up: its 640x640 dummy image has a shape that the video's frames
        # never have, and cuDNN 9 spends ~1 s setting up each new input shape
        self.yolo.predictor.done_warmup = True
        track_history = []
        try:
            for result in tqdm(results, total=video_lwh(video_path)[0], desc="Tracking"):
                frame = []
                if result.boxes.id is not None:
                    track_ids = result.boxes.id.int().cpu().tolist()
                    bbx_xyxy = result.boxes.xyxy.cpu().numpy()  # (N, 4)
                    frame = [{"id": i, "bbx_xyxy": box} for i, box in zip(track_ids, bbx_xyxy)]
                track_history.append(frame)
        finally:
            try:
                results.close()
            finally:
                _close_video_prefetch(self.yolo.predictor)
        return track_history

    def get_main_track(self, video_path):
        """(L, 4) bounding boxes [x1, y1, x2, y2] of the most prominent person, i.e. the track
        with the largest summed box area; frames where it is missing are interpolated."""
        L, w, h = video_lwh(video_path)
        id_to_frame_ids = defaultdict(list)
        id_to_bbx_xyxys = defaultdict(list)
        for frame_id, frame in enumerate(self.track(video_path)):
            for det in frame:
                id_to_frame_ids[det["id"]].append(frame_id)
                id_to_bbx_xyxys[det["id"]].append(det["bbx_xyxy"])
        if not id_to_frame_ids:
            raise RuntimeError(f"No person detected in {video_path}")

        def area_sum(track_id):
            boxes = np.array(id_to_bbx_xyxys[track_id])
            bbx_wh = boxes[:, 2:] - boxes[:, :2]
            return (bbx_wh[:, 0] * bbx_wh[:, 1] / w / h).sum()

        track_id = max(id_to_frame_ids, key=area_sum)  # first one wins ties, like a stable sort
        frame_ids = torch.tensor(id_to_frame_ids[track_id])
        bbx_xyxy = torch.tensor(np.array(id_to_bbx_xyxys[track_id]))  # (N, 4)

        # fill missing frames by linear interpolation, then smooth
        present = torch.zeros(L, dtype=torch.bool)
        present[frame_ids] = True
        if len(frame_ids) < L:
            full = torch.zeros((L, 4), dtype=bbx_xyxy.dtype)
            full[present] = bbx_xyxy
            bbx_xyxy = _interpolate_missing(full, present)
        bbx_xyxy = moving_average_smooth(bbx_xyxy, window_size=5, dim=0)
        return moving_average_smooth(bbx_xyxy, window_size=5, dim=0)


def _interpolate_missing(data, present):
    """Linear interpolation over each run of missing frames; runs at the start/end copy the
    nearest present frame."""
    data = data.clone()
    for missing in _runs_of_true(~present):
        if missing[0] - 1 < 0:
            data[missing] = data[missing[-1] + 1].clone()
        elif missing[-1] + 1 >= len(data):
            data[missing] = data[missing[0] - 1].clone()
        else:
            prev = data[missing[0] - 1]
            next = data[missing[-1] + 1]
            t = torch.linspace(0, 1, len(missing) + 2)[1:-1][:, None]
            data[missing] = t * (next - prev)[None] + prev[None]
    return data


def _runs_of_true(mask):
    """(L,) bool -> list of index tensors, one per run of consecutive True values."""
    padded = torch.cat([torch.tensor([False]), mask, torch.tensor([False])])
    diffs = torch.diff(padded.int())
    starts = (diffs == 1).nonzero().flatten()
    ends = (diffs == -1).nonzero().flatten()
    return [torch.arange(s, e) for s, e in zip(starts, ends)]


class _PrefetchVideo:
    """Wraps ultralytics' video loader to decode in a background thread (at most two batches
    ahead). Only the worker thread touches the OpenCV capture; batches are consumed in order."""

    def __init__(self, loader):
        self.loader = loader
        self._pending = queue.Queue(maxsize=2)
        self._stop = threading.Event()
        self._thread = None

    def __getattr__(self, name):
        return getattr(self.loader, name)

    def __len__(self):
        return len(self.loader)

    def _put(self, item):
        while not self._stop.is_set():
            try:
                self._pending.put(item, timeout=0.1)
                return
            except queue.Full:
                pass

    def _decode(self):
        try:
            for batch in self.loader:
                if self._stop.is_set():
                    break
                self._put((True, batch))
        except Exception as error:
            self._put((False, error))
        finally:
            try:
                if self.loader.cap is not None:
                    self.loader.cap.release()
            finally:
                self._put((True, None))

    def __iter__(self):
        if self._thread is None and not self._stop.is_set():
            self._thread = threading.Thread(target=self._decode, daemon=True)
            self._thread.start()
        return self

    def __next__(self):
        if self._stop.is_set():
            raise StopIteration
        if self._thread is None:
            iter(self)
        ok, item = self._pending.get()
        if not ok:
            self.close()
            raise item
        if item is None:
            self.close()
            raise StopIteration
        return item

    def close(self):
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=5)
            if self._thread.is_alive():
                raise RuntimeError("Video decoder did not stop within five seconds")
        elif self.loader.cap is not None:
            self.loader.cap.release()
        while True:  # release queued frames even if inference exited early
            try:
                self._pending.get_nowait()
            except queue.Empty:
                break


def _start_video_prefetch(predictor):
    loader = predictor.dataset
    if loader.nf != 1 or loader.mode != "video":
        raise ValueError("Tracker decode prefetch expects exactly one video file")
    predictor.dataset = _PrefetchVideo(loader)


def _close_video_prefetch(predictor):
    if isinstance(getattr(predictor, "dataset", None), _PrefetchVideo):
        predictor.dataset.close()