Skip to content

Results Objects

The public API returns structured result objects rather than raw tuples or terminal-only output.

Source code in boxmot/engine/workflow_results.py
@dataclass(**dataclass_slots_kwargs())
class TrackRunResult:
    source: Any
    results: Results
    video_path: Path | None
    text_path: Path | None
    _timings: dict[str, Any] = field(default_factory=dict, repr=False)
    _summary: dict[str, Any] = field(default_factory=dict, repr=False)

    @property
    def timings(self) -> dict[str, Any]:
        self.refresh()
        return self._timings

    @property
    def summary(self) -> dict[str, Any]:
        self.refresh()
        return self._summary

    def __str__(self) -> str:
        return self.render()

    def __repr__(self) -> str:
        self.refresh()
        return (
            f"TrackRunResult(source={self.source!r}, summary={self._summary!r}, "
            f"video_path={self.video_path!r}, text_path={self.text_path!r})"
        )

    def __iter__(self) -> Iterator[Tracks]:
        for track_result in self.results:
            self.refresh()
            yield track_result
        self.refresh()

    def show(self) -> None:
        self.results.show()
        self.refresh()

    def stop(self, reason: str | None = None) -> None:
        self.results.stop(reason)
        self.refresh()

    def format_summary(self) -> str:
        self.refresh()
        return self.results.format_summary()

    def render(self) -> str:
        return self.format_summary()

    def print_summary(self) -> None:
        print(self.render())

    def refresh(self) -> None:
        summary_fn = getattr(self.results, "summary", None)
        if callable(summary_fn):
            self._summary = summary_fn()
        else:
            self._summary = _results_summary_snapshot(self.results, self.source)
        self._timings = _track_timings_from_summary(self._summary)
Source code in boxmot/engine/workflow_results.py
@dataclass(**dataclass_slots_kwargs())
class GenerateResult:
    benchmark: str | None
    source: Any
    cache_dir: Path
    detectors: tuple[Path, ...]
    reid_models: tuple[Path, ...]
    timings: dict[str, Any] = field(default_factory=dict)
    args: Any = None

    def __str__(self) -> str:
        return self.render()

    def __repr__(self) -> str:
        return (
            f"GenerateResult(benchmark={self.benchmark!r}, source={self.source!r}, "
            f"cache_dir={self.cache_dir!r}, detectors={self.detectors!r}, reid_models={self.reid_models!r})"
        )

    def render(self) -> str:
        timing_stats = reporting.timing_stats_from_snapshot(self.timings)
        if timing_stats is not None:
            summary = timing_stats.format_summary()
            if summary:
                return summary

        target = self.benchmark or self.source
        lines = ["GENERATE SUMMARY"]
        if target is not None:
            label = "Benchmark" if self.benchmark else "Source"
            lines.append(f"{label}: {target}")
        lines.append(f"Cache dir: {self.cache_dir}")
        return "\n".join(lines)

    def print_summary(self) -> None:
        print(self.render())

    def to_dict(self) -> dict[str, Any]:
        return {
            "benchmark": self.benchmark,
            "source": None if self.source is None else str(self.source),
            "cache_dir": str(self.cache_dir),
            "detectors": [str(path) for path in self.detectors],
            "reid_models": [str(path) for path in self.reid_models],
            "timings": dict(self.timings),
        }
Source code in boxmot/engine/workflow_results.py
@dataclass(**dataclass_slots_kwargs())
class ValidationResult:
    benchmark: str
    raw: dict[str, Any]
    summary_label: str
    summary: dict[str, Any]
    exp_dir: Path | None = None
    timings: dict[str, Any] = field(default_factory=dict)
    args: Any = None

    def __str__(self) -> str:
        return self.render()

    def __repr__(self) -> str:
        return (
            f"ValidationResult(benchmark={self.benchmark!r}, "
            f"summary={self.summary!r}, exp_dir={self.exp_dir!r})"
        )

    def render(
        self,
        *,
        title: str | None = None,
        include_sequences: bool = True,
        include_timings: bool = False,
    ) -> str:
        return reporting.render_validation_cli_report(
            self.raw,
            args=self.args,
            timings=self.timings,
            title=reporting.CLI_RESULTS_SUMMARY_TITLE if title is None else title,
            include_sequences=include_sequences,
            include_timings=include_timings,
        )

    def format_report(self, *, title: str | None = None, include_sequences: bool = True) -> str:
        report_title = reporting.DEFAULT_VALIDATION_REPORT_TITLE if title is None else title
        return reporting.format_validation_report(
            self.raw,
            args=self.args,
            title=report_title,
            include_sequences=include_sequences,
        )

    def print_report(
        self,
        *,
        title: str | None = None,
        include_sequences: bool = True,
        include_timings: bool = False,
    ) -> None:
        print(
            self.render(
                title=title,
                include_sequences=include_sequences,
                include_timings=include_timings,
            )
        )

    def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
        payload = {
            "benchmark": self.benchmark,
            "summary_label": self.summary_label,
            "summary": dict(self.summary),
            "timings": dict(self.timings),
            "exp_dir": None if self.exp_dir is None else str(self.exp_dir),
        }
        if include_raw:
            payload["raw"] = self.raw
        return payload
Source code in boxmot/engine/workflow_results.py
@dataclass(**dataclass_slots_kwargs())
class TuneResult:
    benchmark: str
    tracker: str
    trials: list[TuneTrialResult]
    best: TuneTrialResult
    best_config: dict[str, Any]
    best_yaml: Path

    @property
    def summary_label(self) -> str:
        return self.best.summary_label

    @property
    def summary(self) -> dict[str, Any]:
        return self.best.summary

    @property
    def raw(self) -> dict[str, Any]:
        return self.best.raw

    @property
    def timings(self) -> dict[str, Any]:
        return self.best.timings

    @property
    def exp_dir(self) -> Path | None:
        return self.best.exp_dir

    @property
    def args(self) -> Any:
        return self.best.args

    @property
    def baseline(self) -> TuneTrialResult:
        return self.trials[0]

    def __str__(self) -> str:
        return self.render()

    def __repr__(self) -> str:
        return (
            f"TuneResult(benchmark={self.benchmark!r}, tracker={self.tracker!r}, "
            f"summary={self.summary!r}, best_config={self.best_config!r}, best_yaml={self.best_yaml!r})"
        )

    def render(
        self,
        *,
        title: str | None = None,
        include_sequences: bool = True,
        include_timings: bool = False,
    ) -> str:
        return reporting.render_validation_cli_report(
            self.best.raw,
            args=self.best.args,
            timings=self.best.timings,
            title=reporting.CLI_TUNE_BEST_SUMMARY_TITLE if title is None else title,
            include_sequences=include_sequences,
            include_timings=include_timings,
            compare_raw=self.baseline.raw,
            compare_args=self.baseline.args,
        )

    def format_report(self, *, title: str | None = None, include_sequences: bool = True) -> str:
        return self.format_best_report(title=title, include_sequences=include_sequences)

    def print_report(
        self,
        *,
        title: str | None = None,
        include_sequences: bool = True,
        include_timings: bool = False,
    ) -> None:
        print(
            self.render(
                title=title,
                include_sequences=include_sequences,
                include_timings=include_timings,
            )
        )

    def format_best_report(self, *, title: str | None = None, include_sequences: bool = True) -> str:
        report_title = reporting.DEFAULT_TUNE_BEST_REPORT_TITLE if title is None else title
        return reporting.format_validation_report(
            self.best.raw,
            args=self.best.args,
            title=report_title,
            include_sequences=include_sequences,
        )

    def print_best_report(
        self,
        *,
        title: str | None = None,
        include_sequences: bool = True,
        include_timings: bool = False,
    ) -> None:
        print(
            self.render(
                title=title,
                include_sequences=include_sequences,
                include_timings=include_timings,
            )
        )

    def to_dict(self, *, include_trials: bool = False, include_raw: bool = False) -> dict[str, Any]:
        payload = {
            "benchmark": self.benchmark,
            "tracker": self.tracker,
            "summary_label": self.summary_label,
            "summary": dict(self.summary),
            "best_config": dict(self.best_config),
            "best_yaml": str(self.best_yaml),
            "best": self.best.to_dict(include_raw=include_raw),
        }
        if include_trials:
            payload["trials"] = [trial.to_dict(include_raw=include_raw) for trial in self.trials]
        return payload
Source code in boxmot/engine/workflow_results.py
@dataclass(**dataclass_slots_kwargs())
class TuneTrialResult:
    index: int
    config: dict[str, Any]
    metrics: ValidationResult
    score: tuple[float, ...]

    @property
    def benchmark(self) -> str:
        return self.metrics.benchmark

    @property
    def raw(self) -> dict[str, Any]:
        return self.metrics.raw

    @property
    def summary_label(self) -> str:
        return self.metrics.summary_label

    @property
    def summary(self) -> dict[str, Any]:
        return self.metrics.summary

    @property
    def timings(self) -> dict[str, Any]:
        return self.metrics.timings

    @property
    def exp_dir(self) -> Path | None:
        return self.metrics.exp_dir

    @property
    def args(self) -> Any:
        return self.metrics.args

    def __str__(self) -> str:
        return self.render()

    def __repr__(self) -> str:
        return (
            f"TuneTrialResult(index={self.index}, summary={self.summary!r}, "
            f"config={self.config!r}, exp_dir={self.exp_dir!r})"
        )

    def render(
        self,
        *,
        title: str | None = None,
        include_sequences: bool = True,
        include_timings: bool = False,
    ) -> str:
        return self.metrics.render(
            title=title,
            include_sequences=include_sequences,
            include_timings=include_timings,
        )

    def format_report(self, *, title: str | None = None, include_sequences: bool = True) -> str:
        return self.metrics.format_report(title=title, include_sequences=include_sequences)

    def print_report(
        self,
        *,
        title: str | None = None,
        include_sequences: bool = True,
        include_timings: bool = False,
    ) -> None:
        print(
            self.render(
                title=title,
                include_sequences=include_sequences,
                include_timings=include_timings,
            )
        )

    def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
        return {
            "index": self.index,
            "config": dict(self.config),
            "score": list(self.score),
            "metrics": self.metrics.to_dict(include_raw=include_raw),
        }
Source code in boxmot/engine/research.py
@dataclass(**dataclass_slots_kwargs())
class ResearchResult:
    tracker: str
    benchmark: str
    proposal_model: str
    run_dir: Path
    best_candidate_dir: Path
    editable_files: tuple[str, ...]
    train_sequences: tuple[str, ...]
    val_sequences: tuple[str, ...]
    baseline_summary: dict[str, int | float]
    best_summary: dict[str, int | float]
    delta_summary: dict[str, float]
    workspace_dir: Path | None = None

    def __str__(self) -> str:
        return self.render()

    def render(self) -> str:
        def _format_metrics(metrics: Mapping[str, int | float], *, signed: bool = False) -> str:
            parts = []
            for metric in RESEARCH_METRICS:
                value = metrics.get(metric)
                if isinstance(value, (int, float)):
                    fmt = f"{float(value):+.3f}" if signed else f"{float(value):.3f}"
                    parts.append(f"{metric}={fmt}")
            return " ".join(parts) if parts else "n/a"

        lines = [
            "RESEARCH SUMMARY",
            f"Tracker: {self.tracker}",
            f"Benchmark: {self.benchmark}",
            f"Proposal model: {self.proposal_model}",
            f"Baseline: {_format_metrics(self.baseline_summary)}",
            f"Best: {_format_metrics(self.best_summary)}",
            f"Delta: {_format_metrics(self.delta_summary, signed=True)}",
            f"Best candidate dir: {self.best_candidate_dir}",
        ]
        if self.workspace_dir is not None:
            lines.append(f"Workspace: {self.workspace_dir}")
        return "\n".join(lines)

    def print_summary(self) -> None:
        print(self.render())

    def to_dict(self) -> dict[str, Any]:
        return {
            "tracker": self.tracker,
            "benchmark": self.benchmark,
            "proposal_model": self.proposal_model,
            "run_dir": str(self.run_dir),
            "best_candidate_dir": str(self.best_candidate_dir),
            "editable_files": list(self.editable_files),
            "train_sequences": list(self.train_sequences),
            "val_sequences": list(self.val_sequences),
            "baseline_summary": self.baseline_summary,
            "best_summary": self.best_summary,
            "delta_summary": self.delta_summary,
            "workspace_dir": None if self.workspace_dir is None else str(self.workspace_dir),
        }
Source code in boxmot/engine/workflow_results.py
@dataclass(**dataclass_slots_kwargs())
class ExportResult:
    weights: Path
    files: dict[str, Any]
Source code in boxmot/engine/results.py
class Results:
    def __init__(self, source, detector: Any, reid: Any, tracker: Any, verbose: bool = True, drawer: Drawer | None = None) -> None:
        if detector is None:
            raise ValueError("A detector instance is required.")
        if tracker is None:
            raise ValueError("A tracker instance is required.")

        self.source = source
        self.detector = detector
        self.reid = reid
        self.tracker = tracker
        self.verbose = bool(verbose)
        self.drawer = drawer
        self._generator: Iterator[Tracks] | None = None
        self._cache: list[Tracks] = []
        self._cache_results = not _is_live_source(source)
        self._exhausted = False
        self._interrupted = False
        self._track_ids_seen: set[int] = set()
        self.totals = {
            "det": 0.0,
            "reid": 0.0,
            "track": 0.0,
            "total": 0.0,
            "frames": 0,
            "detections": 0,
            "tracks": 0,
        }

    def __iter__(self):
        if self._exhausted:
            return iter(self._cache)
        if self._generator is None:
            self._generator = self._process()
        return self

    def __next__(self) -> Tracks:
        if self._generator is None:
            self._generator = self._process()
        try:
            result = next(self._generator)
        except StopIteration:
            self._exhausted = True
            raise
        if self._cache_results:
            self._cache.append(result)
        return result

    @staticmethod
    def _as_2d_array(values: Any, empty_cols: int = 0) -> np.ndarray:
        arr = np.asarray(values, dtype=np.float32)
        if arr.size == 0:
            cols = arr.shape[1] if arr.ndim == 2 else empty_cols
            return np.empty((0, cols), dtype=np.float32)
        if arr.ndim == 1:
            return arr.reshape(1, -1)
        return arr

    @staticmethod
    def _extract_detections(output: Any) -> np.ndarray:
        if isinstance(output, (list, tuple)) and len(output) == 1:
            output = output[0]
        if isinstance(output, Detections):
            cols = output.dets.shape[1] if output.dets.ndim == 2 else (7 if output.is_obb else 6)
            return Results._as_2d_array(output.dets, empty_cols=cols)
        if hasattr(output, "dets"):
            dets = getattr(output, "dets")
            cols = dets.shape[1] if isinstance(dets, np.ndarray) and dets.ndim == 2 else 6
            return Results._as_2d_array(dets, empty_cols=cols)
        if output is None:
            return np.empty((0, 6), dtype=np.float32)
        return Results._as_2d_array(output, empty_cols=6)

    def _iter_frames(self):
        source = self.source
        if isinstance(source, (str, Path)):
            source_path = Path(source)
            if source_path.is_dir() and (source_path / "img1").is_dir():
                source = source_path / "img1"
        yield from iter_source(source)

    def _log_frame_timings(self, frame_idx: int, det_ms: float, reid_ms: float, track_ms: float) -> None:
        total_ms = det_ms + reid_ms + track_ms
        if self.reid is None:
            LOGGER.info(
                f"Frame {frame_idx} | Det: {det_ms:.1f}ms | Track: {track_ms:.1f}ms | Total: {total_ms:.1f}ms"
            )
            return
        LOGGER.info(
            f"Frame {frame_idx} | Det: {det_ms:.1f}ms | ReID: {reid_ms:.1f}ms | Track: {track_ms:.1f}ms | Total: {total_ms:.1f}ms"
        )

    def _log_summary(self) -> None:
        self.print_summary()

    def _run_reid(self, frame: np.ndarray, dets: np.ndarray) -> np.ndarray | None:
        if self.reid is None:
            return None
        try:
            return self.reid(frame, boxes=dets)
        except TypeError:
            return self.reid(frame, dets)

    def _run_tracker(self, dets: np.ndarray, frame: np.ndarray, features: np.ndarray | None) -> np.ndarray:
        if features is None:
            return self._as_2d_array(self.tracker.update(dets, frame), empty_cols=8)
        try:
            tracks = self.tracker.update(dets, frame, features)
        except TypeError:
            tracks = self.tracker.update(dets, frame)
        return self._as_2d_array(tracks, empty_cols=8)

    @staticmethod
    def _extract_track_ids(tracks: np.ndarray) -> set[int]:
        arr = np.asarray(tracks, dtype=np.float32)
        if arr.size == 0 or arr.ndim != 2:
            return set()
        if arr.shape[1] >= 9:
            return {int(track_id) for track_id in arr[:, 5].tolist()}
        if arr.shape[1] >= 8:
            return {int(track_id) for track_id in arr[:, 4].tolist()}
        return set()

    def _summary_snapshot(self) -> dict[str, Any]:
        frames = int(self.totals["frames"])
        avg_total = (self.totals["total"] / frames) if frames else 0.0
        return {
            "source": str(self.source),
            "frames": frames,
            "detections": int(self.totals["detections"]),
            "tracks": int(self.totals["tracks"]),
            "unique_tracks": len(self._track_ids_seen),
            "timings_ms": {
                "det": float(self.totals["det"]),
                "reid": float(self.totals["reid"]),
                "track": float(self.totals["track"]),
                "total": float(self.totals["total"]),
                "avg_total": float(avg_total),
            },
        }

    def stop(self, reason: str | None = None) -> None:
        if self._exhausted:
            return

        self._interrupted = True
        if reason:
            LOGGER.info(reason)

        generator = self._generator
        self._generator = None
        if generator is not None:
            generator.close()
        else:
            self._exhausted = True

    def format_summary(self) -> str:
        summary = self.summary()
        timings = summary["timings_ms"]
        frames = max(int(summary["frames"]), 1)
        width = 86

        def _fps(total_ms: float) -> float:
            avg_ms = float(total_ms) / frames if frames else 0.0
            return (1000.0 / avg_ms) if avg_ms else 0.0

        lines = [
            "=" * width,
            f"{'TRACKING SUMMARY':^{width}}",
            "=" * width,
            f"Source:      {summary['source']}",
            f"Frames:      {summary['frames']}",
            f"Detections:  {summary['detections']}",
            f"Track rows:  {summary['tracks']}",
            f"Unique IDs:  {summary.get('unique_tracks', 0)}",
            "-" * width,
            f"{'Component':<14} {'Total (ms)':>12} {'Avg (ms)':>12} {'FPS':>10}",
            "-" * width,
            f"{'Detection':<14} {timings['det']:>12.1f} {(timings['det'] / frames if frames else 0.0):>12.2f} {_fps(timings['det']):>10.1f}",
            f"{'ReID':<14} {timings['reid']:>12.1f} {(timings['reid'] / frames if frames else 0.0):>12.2f} {_fps(timings['reid']):>10.1f}",
            f"{'Tracking':<14} {timings['track']:>12.1f} {(timings['track'] / frames if frames else 0.0):>12.2f} {_fps(timings['track']):>10.1f}",
            "-" * width,
            f"{'Total':<14} {timings['total']:>12.1f} {timings['avg_total']:>12.2f} {_fps(timings['total']):>10.1f}",
            "=" * width,
        ]
        return "\n".join(lines)

    def print_summary(self) -> None:
        frames = int(self.totals["frames"])
        if frames == 0:
            return

        for index, line in enumerate(self.format_summary().splitlines()):
            if line and set(line) == {"="}:
                LOGGER.opt(colors=True).info(f"<blue>{line}</blue>")
            elif line and set(line) == {"-"}:
                LOGGER.opt(colors=True).info(f"<blue>{line}</blue>")
            elif index == 1:
                LOGGER.opt(colors=True).info(f"<bold><cyan>{line}</cyan></bold>")
            else:
                LOGGER.info(line)

    def _process(self):
        if hasattr(self.tracker, "reset"):
            self.tracker.reset()

        try:
            for frame_idx, (path, frame) in enumerate(self._iter_frames(), start=1):
                det_started = time.perf_counter()
                detector_output = self.detector(frame)
                dets = self._extract_detections(detector_output)
                det_ms = (time.perf_counter() - det_started) * 1000

                reid_ms = 0.0
                if self.reid is not None:
                    reid_started = time.perf_counter()
                    features = self._run_reid(frame, dets)
                    reid_ms = (time.perf_counter() - reid_started) * 1000
                else:
                    features = None

                track_started = time.perf_counter()
                tracks = self._run_tracker(dets, frame, features)
                track_ms = (time.perf_counter() - track_started) * 1000

                total_ms = det_ms + reid_ms + track_ms
                self.totals["det"] += det_ms
                self.totals["reid"] += reid_ms
                self.totals["track"] += track_ms
                self.totals["total"] += total_ms
                self.totals["frames"] += 1
                self.totals["detections"] += int(dets.shape[0])
                self.totals["tracks"] += int(tracks.shape[0])
                self._track_ids_seen.update(self._extract_track_ids(tracks))

                if self.verbose:
                    self._log_frame_timings(frame_idx, det_ms, reid_ms, track_ms)

                yield Tracks(
                    frame_idx=frame_idx,
                    frame=frame,
                    tracks=tracks,
                    detections=dets,
                    source_path=path,
                    get_drawer=lambda: self.drawer,
                    stop_session=self.stop,
                )
        except KeyboardInterrupt:
            self._interrupted = True
            LOGGER.info("Tracking interrupted by user.")
            return
        finally:
            self._exhausted = True
            if self.verbose:
                self._log_summary()

    def materialize(self) -> list[Tracks]:
        while not self._exhausted:
            try:
                next(self)
            except StopIteration:
                break
        return self._cache

    def save(self, output_path: str | Path) -> Path:
        path = Path(output_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        if path.exists():
            path.unlink()
        for track_result in self.materialize():
            write_mot_results(path, track_result.to_mot())
        return path

    def summary(self) -> dict[str, Any]:
        if not self._exhausted and not self._interrupted and not _is_live_source(self.source):
            self.materialize()
        return self._summary_snapshot()

    def show(self) -> None:
        for track_result in self:
            if not track_result.show():
                break
        cv2.destroyAllWindows()
Source code in boxmot/engine/results.py
class Tracks:
    def __init__(
        self,
        frame_idx: int,
        frame: np.ndarray,
        tracks: np.ndarray,
        detections: np.ndarray | None,
        source_path: str,
        get_drawer: Callable[[], Drawer | None],
        stop_session: Callable[[str | None], None] | None = None,
    ) -> None:
        self.frame_idx = int(frame_idx)
        self.frame = frame
        self.tracks = self._as_2d_array(tracks)
        self.detections = None if detections is None else self._as_2d_array(detections)
        self.source_path = source_path
        self._get_drawer = get_drawer
        self._stop_session = stop_session

    @staticmethod
    def _as_2d_array(values: Any) -> np.ndarray:
        arr = np.asarray(values, dtype=np.float32)
        if arr.size == 0:
            cols = arr.shape[1] if arr.ndim == 2 else 0
            return np.empty((0, cols), dtype=np.float32)
        if arr.ndim == 1:
            return arr.reshape(1, -1)
        return arr

    @property
    def num_tracks(self) -> int:
        return int(self.tracks.shape[0])

    def _default_draw(self, frame: np.ndarray) -> np.ndarray:
        drawn = frame.copy()
        if self.tracks.size == 0:
            return drawn

        is_obb = self.tracks.shape[1] >= 9
        for track in self.tracks:
            if is_obb:
                cx, cy, width, height, angle = track[:5]
                track_id = int(track[5])
                conf = float(track[6])
                rect = ((float(cx), float(cy)), (max(float(width), 1.0), max(float(height), 1.0)), float(np.degrees(angle)))
                corners = cv2.boxPoints(rect).astype(np.int32)
                cv2.polylines(drawn, [corners], True, _track_color(track_id), 2)
                label_point = tuple(corners[0])
            else:
                x1, y1, x2, y2 = track[:4].round().astype(int)
                track_id = int(track[4])
                conf = float(track[5])
                cv2.rectangle(drawn, (x1, y1), (x2, y2), _track_color(track_id), 2)
                label_point = (x1, max(0, y1 - 6))

            cv2.putText(
                drawn,
                f"{track_id} {conf:.2f}",
                label_point,
                cv2.FONT_HERSHEY_SIMPLEX,
                0.5,
                _track_color(track_id),
                1,
                cv2.LINE_AA,
            )

        return drawn

    def render(self) -> np.ndarray:
        drawer = self._get_drawer()
        if drawer is not None:
            return drawer(self.frame.copy(), self.tracks)
        return self._default_draw(self.frame)

    def show(self, window_name: str = "Tracking") -> bool:
        cv2.imshow(window_name, self.render())
        key = cv2.waitKey(1) & 0xFF
        should_continue = key not in (ord("q"), 27)
        if not should_continue and self._stop_session is not None:
            self._stop_session("Tracking stopped by user.")
        return should_continue

    def to_mot(self) -> np.ndarray:
        if self.tracks.size == 0:
            return np.empty((0, 0), dtype=np.float32)
        if self.tracks.shape[1] >= 9:
            return convert_to_mmot_obb_format(self.tracks, self.frame_idx)
        return convert_to_mot_format(self.tracks, self.frame_idx)

    def __str__(self) -> str:
        rows = self.to_mot()
        if rows.size == 0:
            return ""
        if rows.ndim == 1:
            rows = rows.reshape(1, -1)

        buffer = io.StringIO()
        if rows.shape[1] == 9:
            np.savetxt(buffer, rows, fmt="%d,%d,%d,%d,%d,%d,%.6f,%d,%d")
        else:
            np.savetxt(buffer, rows, fmt="%g", delimiter=",")
        return buffer.getvalue()