Skip to content

Low-level API

Auto-generated reference for the building blocks used by the high-level facade. Use these when you want to compose the detector, ReID runtime, and trackers explicitly.

Detector

Public detector wrapper with overrideable stage hooks and source streaming.

Source code in boxmot/detectors/detector.py
class Detector:
    """Public detector wrapper with overrideable stage hooks and source streaming."""

    def __init__(
        self,
        path: str | Path,
        device: str = "cpu",
        imgsz=None,
        conf: float | None = None,
        iou: float = 0.7,
        classes=None,
        agnostic_nms: bool = False,
        batch: int = 1,
        vid_stride: int = 1,
        callbacks: dict[str, list[Callable[["Detector"], None]]] | None = None,
    ) -> None:
        self.path = Path(path)
        self.device = device
        self.imgsz = default_imgsz(self.path) if imgsz is None else imgsz
        self.conf = default_conf(self.path) if conf is None else float(conf)
        self.iou = float(iou)
        self.classes = classes
        self.agnostic_nms = bool(agnostic_nms)
        self.batch_size = max(int(batch), 1)
        self.vid_stride = max(int(vid_stride), 1)
        self.backend = self._get_backend_class(self.path)(model=self.path, device=device, imgsz=self.imgsz)
        self.is_obb = bool(getattr(self.backend, "is_obb", False))
        self.model = getattr(self.backend, "model", getattr(self.backend, "_yolo", self.backend))
        self.done_warmup = False
        self.dataset = None
        self.results = None
        self.raw_results = None
        self.batch = None
        self.seen = 0
        self.stream = False
        self.callbacks = callbacks or {
            "on_predict_start": [],
            "on_predict_batch_start": [],
            "on_predict_postprocess_end": [],
            "on_predict_end": [],
        }
        self._lock = threading.Lock()
        self._last_orig_imgs: list[np.ndarray] | None = None

    @classmethod
    def _get_backend_class(cls, path: str | Path):
        return get_detector_class(path)

    @staticmethod
    def _as_result_list(results):
        return results if isinstance(results, list) else [results]

    @staticmethod
    def _batch_input(frames: list[np.ndarray]):
        return frames[0] if len(frames) == 1 else frames

    @staticmethod
    def _result_metadata(kwargs) -> tuple[list[np.ndarray], list[str]]:
        frames = kwargs.get("frames")
        if isinstance(frames, (list, tuple)):
            images = list(frames)
        else:
            image = kwargs.get("image")
            images = [] if image is None else [image]

        paths = kwargs.get("paths")
        if isinstance(paths, (list, tuple)):
            source_paths = [str(path) for path in paths]
        elif "path" in kwargs:
            source_paths = [str(kwargs.get("path") or "")]
        else:
            source_paths = []
        return images, source_paths

    @classmethod
    def _attach_result_metadata(cls, results, **kwargs):
        """Backfill source metadata that backend-only stages cannot know."""
        result_list = results if isinstance(results, list) else [results]
        images, paths = cls._result_metadata(kwargs)
        if images and isinstance(results, list) and all(isinstance(result, Detections) for result in result_list):
            if len(result_list) != len(images):
                raise ValueError(f"Detector returned {len(result_list)} results for a batch of {len(images)} images.")
        for index, result in enumerate(result_list):
            if not isinstance(result, Detections):
                continue
            if result.orig_img is None and index < len(images):
                result.orig_img = images[index]
            if not result.path and index < len(paths):
                result.path = paths[index]
        return results

    def setup_source(self, source, batch: int | None = None, vid_stride: int | None = None):
        """Prepare a batched source iterator for predictor-style inference."""
        self.dataset = _iter_batches(
            source,
            batch_size=max(int(self.batch_size if batch is None else batch), 1),
            vid_stride=max(int(self.vid_stride if vid_stride is None else vid_stride), 1),
        )
        return self.dataset

    def run_callbacks(self, event: str) -> None:
        """Run registered callbacks for a predictor lifecycle event."""
        for callback in self.callbacks.get(event, []):
            callback(self)

    def add_callback(self, event: str, func: Callable[["Detector"], None]) -> None:
        """Register a callback for a predictor lifecycle event."""
        self.callbacks.setdefault(event, []).append(func)

    def warmup(self) -> None:
        """Warm up the detector backend with a dummy frame once."""
        if self.done_warmup:
            return

        if isinstance(self.imgsz, (list, tuple)):
            height, width = int(self.imgsz[0]), int(self.imgsz[1])
        else:
            height = width = int(self.imgsz)

        dummy = np.zeros((height, width, 3), dtype=np.uint8)
        try:
            self.backend(
                [dummy],
                conf=self.conf,
                iou=self.iou,
                classes=self.classes,
                agnostic_nms=self.agnostic_nms,
            )
        except Exception as exc:  # noqa: BLE001
            LOGGER.warning(f"Detector warmup failed: {exc}")
        finally:
            self.done_warmup = True

    def preprocess(self, image: np.ndarray, **kwargs):
        images = image if isinstance(image, list) else [image]
        self._last_orig_imgs = images
        backend_pre = getattr(self.backend, "preprocess", None)
        if not callable(backend_pre):
            return image
        try:
            return backend_pre(images)
        except NotImplementedError:
            # Backend without a real preprocess stage (legacy contract):
            # fall back to the no-op pass-through so the composite path
            # ``self.backend(...)`` continues to work in ``process``.
            return image

    def process(self, frame, **kwargs):
        backend_proc = getattr(self.backend, "process", None)
        # Composite path: callers passing inference overrides
        # (conf/iou/classes/agnostic_nms) get the legacy "do everything"
        # semantics, which is what the warmup and standalone
        # ``Detector.process(images, conf=..., ...)`` callers rely on.
        composite_keys = {"conf", "iou", "classes", "agnostic_nms"}
        if any(key in kwargs for key in composite_keys):
            images = frame if isinstance(frame, list) else [frame]
            results = self.backend(
                images,
                conf=float(kwargs.get("conf", self.conf)),
                iou=float(kwargs.get("iou", self.iou)),
                classes=kwargs.get("classes", self.classes),
                agnostic_nms=bool(kwargs.get("agnostic_nms", self.agnostic_nms)),
            )
            if isinstance(results, list) and len(results) == 1:
                return results[0]
            return results
        # Stage path: ``frame`` is the output of ``self.preprocess`` and we
        # want only the model forward so timing reports inference separately
        # from preprocess/postprocess.
        if callable(backend_proc):
            try:
                return backend_proc(frame)
            except NotImplementedError:
                pass
        # Backend has no standalone process stage: fall back to composite.
        images = self._last_orig_imgs or (frame if isinstance(frame, list) else [frame])
        results = self.backend(
            images,
            conf=self.conf,
            iou=self.iou,
            classes=self.classes,
            agnostic_nms=self.agnostic_nms,
        )
        if isinstance(results, list) and len(results) == 1:
            return results[0]
        return results

    def postprocess(self, results, as_detections: bool = False, **kwargs):
        if results is None:
            images, paths = self._result_metadata(kwargs)
            count = max(len(images), len(paths), 1)
            empty_results = [
                Detections.empty(
                    images[index] if index < len(images) else None,
                    is_obb=self.is_obb,
                    path=paths[index] if index < len(paths) else "",
                )
                for index in range(count)
            ]
            if as_detections:
                return empty_results[0] if len(empty_results) == 1 else empty_results
            arrays = [result.dets for result in empty_results]
            return arrays[0] if len(arrays) == 1 else arrays

        backend_post = getattr(self.backend, "postprocess", None)
        # If the backend has a real postprocess stage, route the raw model
        # output through it so NMS/scale-back work shows up in the dedicated
        # timing bucket. Backends without a real stage fall through to the
        # legacy unwrap-only behaviour.
        if callable(backend_post) and not isinstance(results, (Detections,)):
            already_detections = (
                isinstance(results, list) and len(results) > 0 and all(isinstance(r, Detections) for r in results)
            )
            if not already_detections:
                try:
                    results = backend_post(
                        results,
                        conf=float(kwargs.get("conf", self.conf)),
                        iou=float(kwargs.get("iou", self.iou)),
                        classes=kwargs.get("classes", self.classes),
                        agnostic_nms=bool(kwargs.get("agnostic_nms", self.agnostic_nms)),
                    )
                except NotImplementedError:
                    pass
        results = self._attach_result_metadata(results, **kwargs)
        if as_detections:
            if isinstance(results, list) and len(results) == 1:
                return results[0]
            return results
        if isinstance(results, Detections):
            if results.masks is not None:
                return results
            return results.dets
        if hasattr(results, "dets"):
            if getattr(results, "masks", None) is not None:
                return results
            return results.dets
        if isinstance(results, list) and all(isinstance(result, Detections) for result in results):
            converted = [result if result.masks is not None else result.dets for result in results]
            return converted[0] if len(converted) == 1 else converted
        if isinstance(results, list) and all(hasattr(result, "dets") for result in results):
            converted = [result if getattr(result, "masks", None) is not None else result.dets for result in results]
            return converted[0] if len(converted) == 1 else converted
        return results

    def _predict_single(self, source, **kwargs):
        path = str(source) if isinstance(source, (str, Path)) else ""
        image = resolve_image(source)

        with self._lock:
            self.stream = False
            self.batch = ([path], [image])
            self.seen = 0
            self.run_callbacks("on_predict_start")
            self.run_callbacks("on_predict_batch_start")
            preprocessed = self.preprocess(image, path=path, **kwargs)
            raw_results = self.process(preprocessed, path=path)
            self.raw_results = self._as_result_list(raw_results)
            processed = self.postprocess(raw_results, image=image, path=path, **kwargs)
            self.results = self._as_result_list(processed)
            self.seen = len(self.results)
            self.run_callbacks("on_predict_postprocess_end")
            self.run_callbacks("on_predict_end")
            return processed

    def stream_inference(self, source, **kwargs):
        """Stream detector outputs over any supported BoxMOT source."""
        batch_size = max(int(kwargs.pop("batch", self.batch_size)), 1)
        vid_stride = max(int(kwargs.pop("vid_stride", self.vid_stride)), 1)

        with self._lock:
            self.stream = True
            self.seen = 0
            self.setup_source(source, batch=batch_size, vid_stride=vid_stride)
            self.run_callbacks("on_predict_start")
            try:
                for paths, frames in self.dataset:
                    self.batch = (paths, frames)
                    self.run_callbacks("on_predict_batch_start")
                    preprocessed = self.preprocess(self._batch_input(frames), paths=paths, **kwargs)
                    raw_results = self.process(preprocessed, paths=paths)
                    self.raw_results = self._as_result_list(raw_results)
                    processed = self.postprocess(raw_results, frames=frames, paths=paths, **kwargs)
                    self.results = self._as_result_list(processed)
                    self.run_callbacks("on_predict_postprocess_end")
                    for result in self.results:
                        self.seen += 1
                        yield result
            finally:
                self.run_callbacks("on_predict_end")

    def predict_cli(self, source, **kwargs) -> None:
        """Consume streaming inference without accumulating outputs in memory."""
        for _ in self.stream_inference(source, **kwargs):
            pass

    def predict(self, source, **kwargs):
        """Run detector inference on a source and return detections."""
        return self(source, **kwargs)

    def __call__(self, source, stream: bool = False, **kwargs):
        if stream:
            return self.stream_inference(source, **kwargs)
        if _is_single_inference_source(source):
            return self._predict_single(source, **kwargs)
        return list(self.stream_inference(source, **kwargs))

add_callback(event, func)

Register a callback for a predictor lifecycle event.

Source code in boxmot/detectors/detector.py
def add_callback(self, event: str, func: Callable[["Detector"], None]) -> None:
    """Register a callback for a predictor lifecycle event."""
    self.callbacks.setdefault(event, []).append(func)

predict(source, **kwargs)

Run detector inference on a source and return detections.

Source code in boxmot/detectors/detector.py
def predict(self, source, **kwargs):
    """Run detector inference on a source and return detections."""
    return self(source, **kwargs)

predict_cli(source, **kwargs)

Consume streaming inference without accumulating outputs in memory.

Source code in boxmot/detectors/detector.py
def predict_cli(self, source, **kwargs) -> None:
    """Consume streaming inference without accumulating outputs in memory."""
    for _ in self.stream_inference(source, **kwargs):
        pass

run_callbacks(event)

Run registered callbacks for a predictor lifecycle event.

Source code in boxmot/detectors/detector.py
def run_callbacks(self, event: str) -> None:
    """Run registered callbacks for a predictor lifecycle event."""
    for callback in self.callbacks.get(event, []):
        callback(self)

setup_source(source, batch=None, vid_stride=None)

Prepare a batched source iterator for predictor-style inference.

Source code in boxmot/detectors/detector.py
def setup_source(self, source, batch: int | None = None, vid_stride: int | None = None):
    """Prepare a batched source iterator for predictor-style inference."""
    self.dataset = _iter_batches(
        source,
        batch_size=max(int(self.batch_size if batch is None else batch), 1),
        vid_stride=max(int(self.vid_stride if vid_stride is None else vid_stride), 1),
    )
    return self.dataset

stream_inference(source, **kwargs)

Stream detector outputs over any supported BoxMOT source.

Source code in boxmot/detectors/detector.py
def stream_inference(self, source, **kwargs):
    """Stream detector outputs over any supported BoxMOT source."""
    batch_size = max(int(kwargs.pop("batch", self.batch_size)), 1)
    vid_stride = max(int(kwargs.pop("vid_stride", self.vid_stride)), 1)

    with self._lock:
        self.stream = True
        self.seen = 0
        self.setup_source(source, batch=batch_size, vid_stride=vid_stride)
        self.run_callbacks("on_predict_start")
        try:
            for paths, frames in self.dataset:
                self.batch = (paths, frames)
                self.run_callbacks("on_predict_batch_start")
                preprocessed = self.preprocess(self._batch_input(frames), paths=paths, **kwargs)
                raw_results = self.process(preprocessed, paths=paths)
                self.raw_results = self._as_result_list(raw_results)
                processed = self.postprocess(raw_results, frames=frames, paths=paths, **kwargs)
                self.results = self._as_result_list(processed)
                self.run_callbacks("on_predict_postprocess_end")
                for result in self.results:
                    self.seen += 1
                    yield result
        finally:
            self.run_callbacks("on_predict_end")

warmup()

Warm up the detector backend with a dummy frame once.

Source code in boxmot/detectors/detector.py
def warmup(self) -> None:
    """Warm up the detector backend with a dummy frame once."""
    if self.done_warmup:
        return

    if isinstance(self.imgsz, (list, tuple)):
        height, width = int(self.imgsz[0]), int(self.imgsz[1])
    else:
        height = width = int(self.imgsz)

    dummy = np.zeros((height, width, 3), dtype=np.uint8)
    try:
        self.backend(
            [dummy],
            conf=self.conf,
            iou=self.iou,
            classes=self.classes,
            agnostic_nms=self.agnostic_nms,
        )
    except Exception as exc:  # noqa: BLE001
        LOGGER.warning(f"Detector warmup failed: {exc}")
    finally:
        self.done_warmup = True

Structured detections

One image's detections in the canonical BoxMOT schema.

Axis-aligned rows have shape (N, 6) and contain [x1, y1, x2, y2, confidence, class]. Oriented rows have shape (N, 7) and contain [cx, cy, width, height, angle, confidence, class]. Angles are expressed in radians.

Source code in boxmot/detectors/base.py
@dataclass
class Detections:
    """One image's detections in the canonical BoxMOT schema.

    Axis-aligned rows have shape ``(N, 6)`` and contain
    ``[x1, y1, x2, y2, confidence, class]``. Oriented rows have shape
    ``(N, 7)`` and contain ``[cx, cy, width, height, angle, confidence, class]``.
    Angles are expressed in radians.
    """

    dets: np.ndarray
    orig_img: np.ndarray | None
    path: str | Path = ""
    names: Mapping[int, str] = field(default_factory=dict)
    masks: np.ndarray | None = None

    def __post_init__(self) -> None:
        self.dets = as_detection_array(self.dets)
        self.path = str(self.path)
        self.names = dict(self.names)
        if self.masks is not None:
            self.masks = np.asarray(self.masks)
            if self.masks.ndim < 1 or len(self.masks) != len(self.dets):
                raise ValueError(
                    "Masks must have one entry per detection; "
                    f"received {len(self.dets)} detections and masks with shape {self.masks.shape}."
                )

    @classmethod
    def empty(
        cls,
        orig_img: np.ndarray | None,
        *,
        is_obb: bool = False,
        path: str | Path = "",
        names: Mapping[int, str] | None = None,
    ) -> Detections:
        """Build an empty result while preserving its AABB or OBB schema."""
        return cls(
            dets=empty_detections(is_obb=is_obb),
            orig_img=orig_img,
            path=path,
            names={} if names is None else names,
        )

    def __array__(self, dtype=None, copy=None) -> np.ndarray:
        if copy is None:
            return np.asarray(self.dets, dtype=dtype)
        return np.array(self.dets, dtype=dtype, copy=copy)

    def __len__(self) -> int:
        return int(self.dets.shape[0])

    def __getitem__(self, item):
        return self.dets[item]

    @property
    def shape(self) -> tuple[int, ...]:
        return self.dets.shape

    @property
    def is_obb(self) -> bool:
        return self.schema.is_obb

    @property
    def schema(self) -> BoxSchema:
        """Canonical schema represented by these detections, including empties."""
        return schema_from_detection_columns(self.dets.shape[1])

    @property
    def boxes(self) -> np.ndarray:
        """Return native box geometry: ``xyxy`` for AABB or ``xywha`` for OBB."""
        return self.dets[:, : self.schema.geometry_cols]

    @property
    def xyxy(self) -> np.ndarray:
        """Return axis-aligned boxes, enclosing each oriented box when needed."""
        if not self.is_obb:
            return self.dets[:, :4]
        if len(self) == 0:
            return np.empty((0, 4), dtype=np.float32)

        cx, cy, width, height, angle = self.dets[:, :5].T.astype(np.float64, copy=False)
        cos_angle = np.abs(np.cos(angle))
        sin_angle = np.abs(np.sin(angle))
        half_width = 0.5 * ((width * cos_angle) + (height * sin_angle))
        half_height = 0.5 * ((width * sin_angle) + (height * cos_angle))
        return np.column_stack((cx - half_width, cy - half_height, cx + half_width, cy + half_height)).astype(
            np.float32
        )

    @property
    def xywha(self) -> np.ndarray:
        if self.is_obb:
            return self.dets[:, :5]
        return np.empty((len(self), 0), dtype=np.float32)

    @property
    def conf(self) -> np.ndarray:
        return self.dets[:, -2]

    @property
    def classes(self) -> np.ndarray:
        return self.dets[:, -1].astype(int)

    @property
    def cls(self) -> np.ndarray:
        return self.classes

boxes property

Return native box geometry: xyxy for AABB or xywha for OBB.

schema property

Canonical schema represented by these detections, including empties.

xyxy property

Return axis-aligned boxes, enclosing each oriented box when needed.

empty(orig_img, *, is_obb=False, path='', names=None) classmethod

Build an empty result while preserving its AABB or OBB schema.

Source code in boxmot/detectors/base.py
@classmethod
def empty(
    cls,
    orig_img: np.ndarray | None,
    *,
    is_obb: bool = False,
    path: str | Path = "",
    names: Mapping[int, str] | None = None,
) -> Detections:
    """Build an empty result while preserving its AABB or OBB schema."""
    return cls(
        dets=empty_detections(is_obb=is_obb),
        orig_img=orig_img,
        path=path,
        names={} if names is None else names,
    )

ReID

Unified ReID runtime that also exposes overrideable public stage hooks.

Source code in boxmot/reid/core/runtime.py
class ReID:
    """Unified ReID runtime that also exposes overrideable public stage hooks."""

    def __init__(
        self,
        path: str | Path | list[str | Path] | tuple[str | Path, ...] | None = None,
        *,
        weights: str | Path | list[str | Path] | tuple[str | Path, ...] | None = None,
        device: str | torch.device = "cpu",
        half: bool = False,
        preprocess_name: str | None = None,
    ) -> None:
        model_ref = path if path is not None else weights
        if model_ref is None:
            model_ref = WEIGHTS / "osnet_x0_25_msmt17.pt"

        primary_weight = model_ref[0] if isinstance(model_ref, (list, tuple)) else model_ref
        self.path = Path(primary_weight)
        self.weights = model_ref
        self.device = device if isinstance(device, torch.device) else select_device(device)
        self.half = bool(half)
        self.preprocess_name = preprocess_name or DEFAULT_PREPROCESS
        self.format = resolve_reid_format(self.path)
        self.backend = self
        self.model = self.get_backend()

    @classmethod
    def from_backend(cls, backend: Any) -> "ReID":
        """Build a ReID runtime around an already-instantiated backend."""
        instance = cls.__new__(cls)
        instance.path = Path(getattr(backend, "weights", "") or "")
        instance.weights = instance.path
        instance.device = getattr(backend, "device", torch.device("cpu"))
        instance.half = bool(getattr(backend, "half", False))
        instance.preprocess_name = DEFAULT_PREPROCESS
        instance.format = None
        instance.backend = instance
        instance.model = backend
        return instance

    def get_backend(self):
        if hasattr(self, "_backend_model"):
            return self._backend_model

        if not isinstance(self.format, ReIDFormat):
            raise RuntimeError("Cannot select a backend for a wrapped ReID runtime")
        backend_class = get_backend_class(self.format)
        self._backend_model = backend_class(
            self.weights, self.device, self.half, preprocess=self.preprocess_name
        )
        return self._backend_model

    def preprocess(self, inputs, boxes=None, **kwargs):
        """Build the model-ready input batch (cropping + standardization)."""
        if boxes is not None:
            image = resolve_image(inputs)
            coerced = coerce_boxes(boxes)
            if coerced.size == 0:
                empty = torch.empty(
                    (0, 3, *self.model.input_shape),
                    dtype=torch.float16 if self.model.half else torch.float32,
                    device=self.model.device,
                )
                batch = self.model.inference_preprocess(empty)
                return {"mode": "image_boxes", "batch": batch, "empty": True}
            if not hasattr(self.model, "get_crops"):
                return {"mode": "image_boxes", "image": image, "boxes": coerced, "fallback": True}
            batch = self.model.get_crops(coerced, image)
            batch = self.model.inference_preprocess(batch)
            return {"mode": "image_boxes", "batch": batch, "empty": False}

        crops = coerce_crops(inputs)
        if not crops:
            empty = torch.empty(
                (0, 3, *self.model.input_shape),
                dtype=torch.float16 if self.model.half else torch.float32,
                device=self.model.device,
            )
            batch = self.model.inference_preprocess(empty)
            return {"mode": "crops", "batch": batch, "empty": True}

        batch = prepare_crop_batch(
            crops,
            input_shape=self.model.input_shape,
            device=self.model.device,
            half=self.model.half,
            preprocess_fn=get_preprocess_fn(self.preprocess_name),
            mean=self.model.mean_array,
            std=self.model.std_array,
        )
        batch = self.model.inference_preprocess(batch)
        return {"mode": "crops", "batch": batch, "empty": False}

    def process(self, payload, **kwargs):
        """Run the ReID model forward pass."""
        if payload.get("fallback", False):
            return {"_features": self.model.get_features(payload["boxes"], payload["image"])}
        if payload.get("empty", False):
            return None
        with torch.no_grad():
            return self.model.forward(payload["batch"])

    def postprocess(self, features, **kwargs) -> np.ndarray:
        """Move features to numpy and L2-normalize them."""
        if features is None:
            return np.empty((0, 0), dtype=np.float32)
        if isinstance(features, dict) and "_features" in features:
            return np.asarray(features["_features"], dtype=np.float32)
        if not hasattr(self.model, "inference_postprocess"):
            return np.asarray(features, dtype=np.float32)
        features = np.asarray(self.model.inference_postprocess(features), dtype=np.float32)
        if features.size == 0:
            return np.empty((0, 0), dtype=np.float32)
        norms = np.linalg.norm(features, axis=-1, keepdims=True)
        norms[norms == 0] = 1.0
        return features / norms

    def __call__(self, inputs, boxes=None, **kwargs) -> np.ndarray:
        payload = self.preprocess(inputs, boxes=boxes, **kwargs)
        features = self.process(payload, boxes=boxes, **kwargs)
        return self.postprocess(features, boxes=boxes, **kwargs)

from_backend(backend) classmethod

Build a ReID runtime around an already-instantiated backend.

Source code in boxmot/reid/core/runtime.py
@classmethod
def from_backend(cls, backend: Any) -> "ReID":
    """Build a ReID runtime around an already-instantiated backend."""
    instance = cls.__new__(cls)
    instance.path = Path(getattr(backend, "weights", "") or "")
    instance.weights = instance.path
    instance.device = getattr(backend, "device", torch.device("cpu"))
    instance.half = bool(getattr(backend, "half", False))
    instance.preprocess_name = DEFAULT_PREPROCESS
    instance.format = None
    instance.backend = instance
    instance.model = backend
    return instance

postprocess(features, **kwargs)

Move features to numpy and L2-normalize them.

Source code in boxmot/reid/core/runtime.py
def postprocess(self, features, **kwargs) -> np.ndarray:
    """Move features to numpy and L2-normalize them."""
    if features is None:
        return np.empty((0, 0), dtype=np.float32)
    if isinstance(features, dict) and "_features" in features:
        return np.asarray(features["_features"], dtype=np.float32)
    if not hasattr(self.model, "inference_postprocess"):
        return np.asarray(features, dtype=np.float32)
    features = np.asarray(self.model.inference_postprocess(features), dtype=np.float32)
    if features.size == 0:
        return np.empty((0, 0), dtype=np.float32)
    norms = np.linalg.norm(features, axis=-1, keepdims=True)
    norms[norms == 0] = 1.0
    return features / norms

preprocess(inputs, boxes=None, **kwargs)

Build the model-ready input batch (cropping + standardization).

Source code in boxmot/reid/core/runtime.py
def preprocess(self, inputs, boxes=None, **kwargs):
    """Build the model-ready input batch (cropping + standardization)."""
    if boxes is not None:
        image = resolve_image(inputs)
        coerced = coerce_boxes(boxes)
        if coerced.size == 0:
            empty = torch.empty(
                (0, 3, *self.model.input_shape),
                dtype=torch.float16 if self.model.half else torch.float32,
                device=self.model.device,
            )
            batch = self.model.inference_preprocess(empty)
            return {"mode": "image_boxes", "batch": batch, "empty": True}
        if not hasattr(self.model, "get_crops"):
            return {"mode": "image_boxes", "image": image, "boxes": coerced, "fallback": True}
        batch = self.model.get_crops(coerced, image)
        batch = self.model.inference_preprocess(batch)
        return {"mode": "image_boxes", "batch": batch, "empty": False}

    crops = coerce_crops(inputs)
    if not crops:
        empty = torch.empty(
            (0, 3, *self.model.input_shape),
            dtype=torch.float16 if self.model.half else torch.float32,
            device=self.model.device,
        )
        batch = self.model.inference_preprocess(empty)
        return {"mode": "crops", "batch": batch, "empty": True}

    batch = prepare_crop_batch(
        crops,
        input_shape=self.model.input_shape,
        device=self.model.device,
        half=self.model.half,
        preprocess_fn=get_preprocess_fn(self.preprocess_name),
        mean=self.model.mean_array,
        std=self.model.std_array,
    )
    batch = self.model.inference_preprocess(batch)
    return {"mode": "crops", "batch": batch, "empty": False}

process(payload, **kwargs)

Run the ReID model forward pass.

Source code in boxmot/reid/core/runtime.py
def process(self, payload, **kwargs):
    """Run the ReID model forward pass."""
    if payload.get("fallback", False):
        return {"_features": self.model.get_features(payload["boxes"], payload["image"])}
    if payload.get("empty", False):
        return None
    with torch.no_grad():
        return self.model.forward(payload["batch"])

Tracker factory

Creates and returns an instance of the specified tracker type.

Parameters: - tracker_type: The type of the tracker (e.g., 'strongsort', 'ocsort'). - tracker_config: Path to the tracker configuration file. - reid_weights: Weights for ReID (re-identification). Used to build a ReID backend when reid_model is not supplied. - device: Device to run the ReID backend on (only used when building from reid_weights). - half: Whether to use half-precision for the ReID backend (only used when building from reid_weights). - per_class: Boolean for class-specific tracking (optional). - class_ids: Optional detector class IDs allowed by this tracker. - class_names: Optional detector class names keyed by detector class ID. - evolve_param_dict: A dictionary of parameters for evolving the tracker. - tracker_kwargs: Constructor overrides applied after default YAML config resolution. - reid_preprocess: Preprocessing method for the ReID backend (only used when building from reid_weights). - reid_model: Pre-built ReID backend (e.g., ReID(...).model). Takes precedence over reid_weights and lets callers share a single backend across trackers. - tracker_backend: Backend to use for the tracker. "python" (default) uses the pure-Python implementation under boxmot.trackers. "cpp" delegates to the registered native (C++) live backend via :func:boxmot.native.registry.get_native_live_backend. The native backend is built on demand if it isn't already compiled. - precomputed_reid: Whether appearance embeddings will be supplied by the caller. When enabled, ReID-capable trackers keep appearance matching active without constructing a live ReID backend from reid_weights. - warmup_model: Whether to warm a tracker-owned ReID backend after construction. Disable this when the caller already warmed a backend shared by multiple trackers.

Returns: - An instance of the selected tracker.

  • ValueError: If tracker_type is not recognized or the requested tracker_backend is not available for that tracker.
Source code in boxmot/trackers/registry.py
def create_tracker(
    tracker_type,
    tracker_config=None,
    reid_weights=None,
    device=None,
    half=None,
    per_class=None,
    class_ids=None,
    class_names=None,
    evolve_param_dict=None,
    tracker_kwargs=None,
    reid_preprocess=None,
    reid_model=None,
    tracker_backend="python",
    precomputed_reid: bool = False,
    warmup_model: bool = True,
):
    """
    Creates and returns an instance of the specified tracker type.

    Parameters:
    - tracker_type: The type of the tracker (e.g., 'strongsort', 'ocsort').
    - tracker_config: Path to the tracker configuration file.
    - reid_weights: Weights for ReID (re-identification). Used to build a ReID backend
        when ``reid_model`` is not supplied.
    - device: Device to run the ReID backend on (only used when building from ``reid_weights``).
    - half: Whether to use half-precision for the ReID backend (only used when building from ``reid_weights``).
    - per_class: Boolean for class-specific tracking (optional).
    - class_ids: Optional detector class IDs allowed by this tracker.
    - class_names: Optional detector class names keyed by detector class ID.
    - evolve_param_dict: A dictionary of parameters for evolving the tracker.
    - tracker_kwargs: Constructor overrides applied after default YAML config resolution.
    - reid_preprocess: Preprocessing method for the ReID backend (only used when building from ``reid_weights``).
    - reid_model: Pre-built ReID backend (e.g., ``ReID(...).model``). Takes
        precedence over ``reid_weights`` and lets callers share a single backend across trackers.
    - tracker_backend: Backend to use for the tracker. ``"python"`` (default)
        uses the pure-Python implementation under ``boxmot.trackers``. ``"cpp"``
        delegates to the registered native (C++) live backend via
        :func:`boxmot.native.registry.get_native_live_backend`. The native
        backend is built on demand if it isn't already compiled.
    - precomputed_reid: Whether appearance embeddings will be supplied by the
        caller. When enabled, ReID-capable trackers keep appearance matching
        active without constructing a live ReID backend from ``reid_weights``.
    - warmup_model: Whether to warm a tracker-owned ReID backend after construction.
        Disable this when the caller already warmed a backend shared by multiple trackers.

    Returns:
    - An instance of the selected tracker.

    Raises:
    - ValueError: If `tracker_type` is not recognized or the requested
      ``tracker_backend`` is not available for that tracker.
    """

    backend = normalize_tracker_backend(tracker_backend, default="python")
    definition = TRACKER_DEFINITIONS.get(tracker_type)

    if backend == "cpp":
        if per_class:
            raise NotImplementedError(
                "Native live trackers do not yet provide class-separated state. "
                "Use tracker_backend='python' with per_class=True."
            )
        tracker = _create_native_tracker(
            tracker_type,
            definition=definition,
            tracker_config=tracker_config,
            reid_weights=reid_weights,
            evolve_param_dict=evolve_param_dict,
            tracker_kwargs=tracker_kwargs,
            reid_preprocess=reid_preprocess,
        )
        if hasattr(tracker, "configure_class_catalog"):
            tracker.configure_class_catalog(class_ids=class_ids, class_names=class_names)
        return tracker

    definition = get_tracker_definition(tracker_type)
    tracker_args = _resolve_tracker_args(
        definition,
        tracker_config,
        evolve_param_dict,
        tracker_kwargs,
    )
    tracker_args["per_class"] = per_class
    if class_ids is not None:
        tracker_args["class_ids"] = class_ids
    if class_names is not None:
        tracker_args["class_names"] = class_names

    if definition.needs_reid:
        if precomputed_reid:
            tracker_args["reid_model"] = reid_model
        else:
            tracker_args["reid_model"] = _build_reid_model(
                reid_weights=reid_weights,
                device=device,
                half=half,
                reid_preprocess=reid_preprocess,
                reid_model=reid_model,
            )
            if tracker_args["reid_model"] is None and "with_reid" in tracker_args:
                tracker_args["with_reid"] = False

    if not definition.accepts_per_class:
        tracker_args.pop("per_class", None)

    tracker_class = _load_tracker_class(definition)
    tracker = tracker_class(**tracker_args)
    if warmup_model and definition.warmup_model and hasattr(tracker, "model") and tracker.model is not None:
        tracker.model.warmup()
    return tracker

Returns the path to the tracker configuration file.

Source code in boxmot/trackers/registry.py
def get_tracker_config(tracker_type):
    """Returns the path to the tracker configuration file."""

    definition = TRACKER_DEFINITIONS.get(tracker_type)
    if definition is not None:
        return definition.config_path
    return get_tracker_config_path(tracker_type)

Structured tracker output

Bases: ndarray

Thin zero-copy view over the (N, 8) or (N, 9) tracker output array.

Provides named property accessors and export methods. Complete row slices preserve this type and aligned masks; transformations that change the row contract deliberately return plain NumPy arrays.

AABB columns (8): x1, y1, x2, y2, id, conf, cls, det_ind OBB columns (9): cx, cy, w, h, angle, id, conf, cls, det_ind

Source code in boxmot/trackers/results.py
 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
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
class TrackResults(np.ndarray):
    """Thin zero-copy view over the (N, 8) or (N, 9) tracker output array.

    Provides named property accessors and export methods. Complete row slices
    preserve this type and aligned masks; transformations that change the row
    contract deliberately return plain NumPy arrays.

    AABB columns (8): x1, y1, x2, y2, id, conf, cls, det_ind
    OBB  columns (9): cx, cy, w, h, angle, id, conf, cls, det_ind
    """

    def __new__(
        cls,
        data: np.ndarray,
        masks: np.ndarray = None,
        *,
        schema: BoxSchema | None = None,
        is_obb: bool | None = None,
    ) -> TrackResults:
        arr = np.asarray(data, dtype=np.float32)
        if schema is not None and is_obb is not None:
            requested = get_box_schema_for_mode(is_obb)
            if requested != schema:
                raise ValueError("schema and is_obb describe different tracker output modes.")
        if schema is None and is_obb is not None:
            schema = get_box_schema_for_mode(is_obb)

        if arr.ndim == 1:
            if arr.size == 0:
                if schema is None:
                    raise ValueError("Empty 1D tracker output is ambiguous; provide an 8- or 9-column array.")
                arr = schema.empty_tracks()
            else:
                arr = arr.reshape(1, -1)
        if arr.ndim != 2:
            raise ValueError(f"Tracker output must be a 2D array, got shape {arr.shape}.")

        inferred = schema_from_track_columns(arr.shape[1])
        if schema is None:
            schema = inferred
        elif inferred != schema:
            raise ValueError(
                f"Tracker output has {arr.shape[1]} columns, but {schema.box_type.value} requires {schema.track_cols}."
            )

        if arr.size and not np.isfinite(arr).all():
            raise ValueError("Tracker output must contain only finite values.")
        if arr.size:
            geometry = arr[:, : schema.geometry_cols]
            if schema.is_obb:
                if np.any(geometry[:, 2:4] <= 0):
                    raise ValueError("OBB tracker output must have positive width and height.")
            elif np.any(geometry[:, 2] <= geometry[:, 0]) or np.any(geometry[:, 3] <= geometry[:, 1]):
                raise ValueError("AABB tracker output must satisfy x2 > x1 and y2 > y1.")

            for label, index in (
                ("track IDs", schema.track_id_index),
                ("class IDs", schema.track_class_index),
                ("detection indices", schema.track_detection_index),
            ):
                values = arr[:, index]
                if not np.equal(values, np.floor(values)).all():
                    raise ValueError(f"Tracker output {label} must be integers.")

        masks_arr = None if masks is None else np.asarray(masks)
        if masks_arr is not None:
            if masks_arr.ndim != 3:
                raise ValueError(f"Tracker masks must have shape (N, H, W), got {masks_arr.shape}.")
            if len(masks_arr) != len(arr):
                raise ValueError(
                    f"Tracker mask count must match output rows, got masks={len(masks_arr)} tracks={len(arr)}."
                )
        obj = arr.view(cls)
        obj._masks = masks_arr
        obj._schema = schema
        return obj

    def __array_finalize__(self, obj):
        # NumPy invokes this hook for many operations that can reorder rows
        # without exposing their indices (take, roll, delete, sort, ...).
        # Metadata is restored explicitly only by known row-preserving paths.
        self._schema = None
        self._masks = None

    @staticmethod
    def _plain_array_tree(value):
        if isinstance(value, TrackResults):
            return np.asarray(value)
        if isinstance(value, tuple):
            return tuple(TrackResults._plain_array_tree(item) for item in value)
        if isinstance(value, list):
            return [TrackResults._plain_array_tree(item) for item in value]
        if isinstance(value, dict):
            return {key: TrackResults._plain_array_tree(item) for key, item in value.items()}
        return value

    def __array_function__(self, func, types, args, kwargs):
        """Keep arbitrary NumPy transformations outside the typed row wrapper."""
        del types
        return func(
            *self._plain_array_tree(args),
            **self._plain_array_tree(kwargs),
        )

    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        """Return plain arrays for computations that do not preserve track semantics."""
        array_inputs = tuple(np.asarray(value) if isinstance(value, TrackResults) else value for value in inputs)
        if "out" in kwargs and kwargs["out"] is not None:
            kwargs["out"] = tuple(
                np.asarray(value) if isinstance(value, TrackResults) else value for value in kwargs["out"]
            )
        return getattr(ufunc, method)(*array_inputs, **kwargs)

    def reshape(self, *shape, **kwargs) -> np.ndarray:
        """Reshaping changes the row contract, so return a plain ndarray."""
        return np.asarray(self).reshape(*shape, **kwargs)

    def transpose(self, *axes) -> np.ndarray:
        """Transposition changes the row contract, so return a plain ndarray."""
        return np.asarray(self).transpose(*axes)

    @property
    def T(self) -> np.ndarray:  # noqa: N802 - NumPy-compatible public attribute
        return np.asarray(self).T

    def ravel(self, order: str = "C") -> np.ndarray:
        return np.asarray(self).ravel(order)

    def flatten(self, order: str = "C") -> np.ndarray:
        return np.asarray(self).flatten(order)

    def squeeze(self, axis=None) -> np.ndarray:
        return np.asarray(self).squeeze(axis=axis)

    def swapaxes(self, axis1: int, axis2: int) -> np.ndarray:
        return np.asarray(self).swapaxes(axis1, axis2)

    def take(self, indices, axis=None, out=None, mode="raise") -> np.ndarray:
        return np.asarray(self).take(indices, axis=axis, out=out, mode=mode)

    def repeat(self, repeats, axis=None) -> np.ndarray:
        return np.asarray(self).repeat(repeats, axis=axis)

    def compress(self, condition, axis=None, out=None) -> np.ndarray:
        return np.asarray(self).compress(condition, axis=axis, out=out)

    def astype(self, dtype, order="K", casting="unsafe", subok=True, copy=True) -> np.ndarray:
        del subok
        return np.asarray(self).astype(dtype, order=order, casting=casting, subok=False, copy=copy)

    def byteswap(self, inplace=False) -> np.ndarray:
        return np.asarray(self).byteswap(inplace=inplace)

    def view(self, dtype=None, type=None) -> np.ndarray:
        values = np.asarray(self)
        if dtype is None and type is None:
            return values.view()
        if type is None:
            return values.view(dtype=dtype)
        if dtype is None:
            return values.view(type=type)
        return values.view(dtype=dtype, type=type)

    def getfield(self, dtype=None, offset=0) -> np.ndarray:
        return np.asarray(self).getfield(dtype=dtype, offset=offset)

    def copy(self, order="C") -> TrackResults:
        masks = None if self._masks is None else np.array(self._masks, copy=True)
        return TrackResults(np.array(self, copy=True, order=order), masks=masks, schema=self.schema)

    def __copy__(self) -> TrackResults:
        return self.copy()

    def __deepcopy__(self, memo) -> TrackResults:
        copied = self.copy()
        memo[id(self)] = copied
        return copied

    def __reduce__(self):
        masks = None if self._masks is None else np.array(self._masks, copy=True)
        return _restore_track_results, (np.array(self, copy=True), masks, self.schema)

    def __getitem__(self, key):
        """Slice optional masks with the same row selection as track rows."""
        result = super().__getitem__(key)
        masks = self._masks
        if not isinstance(result, TrackResults):
            return result

        if self.ndim != 2 or result.ndim != 2 or result.shape[1] != self.schema.track_cols:
            return np.asarray(result)

        if self.ndim == 2 and isinstance(key, tuple):
            if len(key) != 2:
                return np.asarray(result)
            column_key = key[1]
            full_columns = column_key is Ellipsis or (
                isinstance(column_key, slice)
                and column_key.start is None
                and column_key.stop is None
                and column_key.step is None
            )
            if not full_columns:
                return np.asarray(result)
        elif self.ndim == 2:
            key_array = np.asarray(key) if isinstance(key, (list, np.ndarray)) else None
            if key is None or (key_array is not None and key_array.ndim != 1):
                return np.asarray(result)

        result._schema = self.schema
        if masks is None:
            return result

        row_key = key[0] if isinstance(key, tuple) else key
        selected = np.asarray(masks)[row_key]
        if np.asarray(selected).ndim == np.asarray(masks).ndim - 1:
            selected = np.expand_dims(selected, axis=0)
        result._masks = selected
        return result

    @property
    def masks(self) -> np.ndarray | None:
        """Segmentation masks for tracked objects, shape (M, H, W) or None."""
        return self._masks

    @property
    def schema(self) -> BoxSchema:
        """Canonical schema carried by this result, including empty results."""
        if self._schema is None:
            raise ValueError("Tracker result schema metadata is unavailable.")
        if self.ndim != 2 or self.shape[1] != self._schema.track_cols:
            raise ValueError(
                f"Tracker result shape {self.shape} no longer matches its {self._schema.box_type.value} schema."
            )
        if self._masks is not None and len(self._masks) != self.shape[0]:
            raise ValueError(f"Tracker mask count {len(self._masks)} no longer matches {self.shape[0]} result rows.")
        return self._schema

    @property
    def is_obb(self) -> bool:
        """Whether the results contain oriented bounding boxes."""
        return self.schema.is_obb

    def _rows(self) -> np.ndarray:
        values = np.asarray(self)
        if values.ndim == 1:
            return values.reshape(1, -1)
        if values.ndim != 2:
            raise ValueError(f"Tracker results must have one or two dimensions, got {values.shape}.")
        return values

    # ------------------------------------------------------------------
    # Box geometry
    # ------------------------------------------------------------------

    @property
    def boxes(self) -> np.ndarray:
        """Return native geometry: ``xyxy`` for AABB or ``xywha`` for OBB."""
        return self.xywha if self.is_obb else self.xyxy

    @property
    def xyxy(self) -> np.ndarray:
        """Return AABBs, enclosing each oriented track when in OBB mode."""
        rows = self._rows()
        if self.is_obb:
            return xywha_to_xyxy(rows[:, : self.schema.geometry_cols])
        return rows[:, : self.schema.geometry_cols]

    @property
    def xywh(self) -> np.ndarray:
        """Bounding boxes as (x_center, y_center, width, height)."""
        boxes = self._rows()[:, :4]
        if boxes.size == 0:
            return np.empty((0, 4), dtype=np.float32)
        if self.is_obb:
            return boxes
        x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
        return np.stack([(x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1], axis=1)

    @property
    def xywha(self) -> np.ndarray:
        """Oriented boxes as (cx, cy, w, h, angle). OBB mode only."""
        if not self.is_obb:
            return np.empty((len(self), 0), dtype=np.float32)
        return self._rows()[:, : self.schema.geometry_cols]

    # ------------------------------------------------------------------
    # Track metadata
    # ------------------------------------------------------------------

    @property
    def id(self) -> np.ndarray:
        """Integer track IDs."""
        return np.asarray(self._rows()[:, self.schema.track_id_index], dtype=int)

    @property
    def conf(self) -> np.ndarray:
        """Detection confidence scores."""
        return np.asarray(self._rows()[:, self.schema.track_conf_index])

    @property
    def cls(self) -> np.ndarray:
        """Integer class IDs."""
        return np.asarray(self._rows()[:, self.schema.track_class_index], dtype=int)

    @property
    def det_ind(self) -> np.ndarray:
        """Detection indices mapping tracks back to input detections (-1 if unmatched)."""
        return np.asarray(self._rows()[:, self.schema.track_detection_index], dtype=int)

    # ------------------------------------------------------------------
    # Export methods
    # ------------------------------------------------------------------

    @property
    def _csv_fields(self) -> list[str]:
        """Column names for CSV export."""
        if self.is_obb:
            return ["cx", "cy", "w", "h", "angle", "id", "conf", "cls", "det_ind"]
        return ["x1", "y1", "x2", "y2", "id", "conf", "cls", "det_ind"]

    def _row(self, i: int) -> list[Any]:
        """Build a single export row from named accessors."""
        box = [float(v) for v in (self.xywha[i] if self.is_obb else self.xyxy[i])]
        return box + [int(self.id[i]), float(self.conf[i]), int(self.cls[i]), int(self.det_ind[i])]

    def summary(self) -> list[dict[str, Any]]:
        """Convert track results to a list of dictionaries.

        Returns:
            list[dict]: One dict per track with keys: id, conf, cls,
                and either 'box' with x1/y1/x2/y2 (AABB) or cx/cy/w/h/angle (OBB).
        """
        results = []
        for i in range(len(self)):
            entry: dict[str, Any] = {"id": int(self.id[i]), "conf": float(self.conf[i]), "cls": int(self.cls[i])}
            if self.is_obb:
                cx, cy, w, h, angle = self.xywha[i]
                entry["box"] = {"cx": float(cx), "cy": float(cy), "w": float(w), "h": float(h), "angle": float(angle)}
            else:
                x1, y1, x2, y2 = self.xyxy[i]
                entry["box"] = {"x1": float(x1), "y1": float(y1), "x2": float(x2), "y2": float(y2)}
            results.append(entry)
        return results

    def to_json(self, indent: int | None = None) -> str:
        """Convert track results to a JSON string.

        Args:
            indent: JSON indentation level. None for compact output.

        Returns:
            str: JSON-encoded string of the track summaries.
        """
        return json.dumps(self.summary(), indent=indent)

    def to_csv(self, frame_id: int | None = None) -> str:
        """Convert track results to CSV-formatted string.

        Args:
            frame_id: Optional frame number to include as the first column.

        Returns:
            str: CSV string with one row per track.
        """
        buf = io.StringIO()
        writer = csv.writer(buf)
        for i in range(len(self)):
            row = [frame_id] + self._row(i) if frame_id is not None else self._row(i)
            writer.writerow(row)
        return buf.getvalue()

    def save_csv(self, path: str | Path, frame_id: int | None = None, header: bool = True) -> None:
        """Append track results to a CSV file.

        Args:
            path: File path to write/append to.
            frame_id: Optional frame number to include as the first column.
            header: Write header row if the file doesn't exist yet.
        """
        path = Path(path)
        write_header = header and not path.exists()
        path.parent.mkdir(parents=True, exist_ok=True)

        with open(path, "a", newline="") as f:
            if write_header:
                fields = (["frame"] + self._csv_fields) if frame_id is not None else self._csv_fields
                csv.writer(f).writerow(fields)
            f.write(self.to_csv(frame_id=frame_id))

    def save_mot(self, path: str | Path, frame_id: int = 0) -> None:
        """Append track results in canonical MOT or corner-based MMOT format.

        Args:
            path: File path to append to.
            frame_id: Frame index to serialize.
        """
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        if not len(self):
            path.touch(exist_ok=True)
            return

        frame = np.full((len(self), 1), frame_id, dtype=np.float32)
        track_ids = self.id.reshape(-1, 1).astype(np.float32)
        confidence = self.conf.reshape(-1, 1).astype(np.float32)
        det_ind = self.det_ind.reshape(-1, 1).astype(np.float32)
        if self.is_obb:
            rows = np.column_stack((frame, track_ids, xywha_to_corners(self.xywha), confidence, self.cls, det_ind))
            fmt = "%d,%d," + ",".join(["%.6f"] * 9) + ",%d,%d"
        else:
            xyxy = self.xyxy
            ltwh = np.rint(
                np.column_stack((xyxy[:, 0], xyxy[:, 1], xyxy[:, 2] - xyxy[:, 0], xyxy[:, 3] - xyxy[:, 1]))
            ).astype(np.int32)
            rows = np.column_stack((frame, track_ids, ltwh, confidence, self.cls + 1, det_ind))
            fmt = "%d,%d,%d,%d,%d,%d,%.6f,%d,%d"
        with open(path, "a") as file:
            np.savetxt(file, rows, fmt=fmt)

boxes property

Return native geometry: xyxy for AABB or xywha for OBB.

cls property

Integer class IDs.

conf property

Detection confidence scores.

det_ind property

Detection indices mapping tracks back to input detections (-1 if unmatched).

id property

Integer track IDs.

is_obb property

Whether the results contain oriented bounding boxes.

masks property

Segmentation masks for tracked objects, shape (M, H, W) or None.

schema property

Canonical schema carried by this result, including empty results.

xywh property

Bounding boxes as (x_center, y_center, width, height).

xywha property

Oriented boxes as (cx, cy, w, h, angle). OBB mode only.

xyxy property

Return AABBs, enclosing each oriented track when in OBB mode.

__array_function__(func, types, args, kwargs)

Keep arbitrary NumPy transformations outside the typed row wrapper.

Source code in boxmot/trackers/results.py
def __array_function__(self, func, types, args, kwargs):
    """Keep arbitrary NumPy transformations outside the typed row wrapper."""
    del types
    return func(
        *self._plain_array_tree(args),
        **self._plain_array_tree(kwargs),
    )

__array_ufunc__(ufunc, method, *inputs, **kwargs)

Return plain arrays for computations that do not preserve track semantics.

Source code in boxmot/trackers/results.py
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
    """Return plain arrays for computations that do not preserve track semantics."""
    array_inputs = tuple(np.asarray(value) if isinstance(value, TrackResults) else value for value in inputs)
    if "out" in kwargs and kwargs["out"] is not None:
        kwargs["out"] = tuple(
            np.asarray(value) if isinstance(value, TrackResults) else value for value in kwargs["out"]
        )
    return getattr(ufunc, method)(*array_inputs, **kwargs)

__getitem__(key)

Slice optional masks with the same row selection as track rows.

Source code in boxmot/trackers/results.py
def __getitem__(self, key):
    """Slice optional masks with the same row selection as track rows."""
    result = super().__getitem__(key)
    masks = self._masks
    if not isinstance(result, TrackResults):
        return result

    if self.ndim != 2 or result.ndim != 2 or result.shape[1] != self.schema.track_cols:
        return np.asarray(result)

    if self.ndim == 2 and isinstance(key, tuple):
        if len(key) != 2:
            return np.asarray(result)
        column_key = key[1]
        full_columns = column_key is Ellipsis or (
            isinstance(column_key, slice)
            and column_key.start is None
            and column_key.stop is None
            and column_key.step is None
        )
        if not full_columns:
            return np.asarray(result)
    elif self.ndim == 2:
        key_array = np.asarray(key) if isinstance(key, (list, np.ndarray)) else None
        if key is None or (key_array is not None and key_array.ndim != 1):
            return np.asarray(result)

    result._schema = self.schema
    if masks is None:
        return result

    row_key = key[0] if isinstance(key, tuple) else key
    selected = np.asarray(masks)[row_key]
    if np.asarray(selected).ndim == np.asarray(masks).ndim - 1:
        selected = np.expand_dims(selected, axis=0)
    result._masks = selected
    return result

reshape(*shape, **kwargs)

Reshaping changes the row contract, so return a plain ndarray.

Source code in boxmot/trackers/results.py
def reshape(self, *shape, **kwargs) -> np.ndarray:
    """Reshaping changes the row contract, so return a plain ndarray."""
    return np.asarray(self).reshape(*shape, **kwargs)

save_csv(path, frame_id=None, header=True)

Append track results to a CSV file.

Parameters:

Name Type Description Default
path str | Path

File path to write/append to.

required
frame_id int | None

Optional frame number to include as the first column.

None
header bool

Write header row if the file doesn't exist yet.

True
Source code in boxmot/trackers/results.py
def save_csv(self, path: str | Path, frame_id: int | None = None, header: bool = True) -> None:
    """Append track results to a CSV file.

    Args:
        path: File path to write/append to.
        frame_id: Optional frame number to include as the first column.
        header: Write header row if the file doesn't exist yet.
    """
    path = Path(path)
    write_header = header and not path.exists()
    path.parent.mkdir(parents=True, exist_ok=True)

    with open(path, "a", newline="") as f:
        if write_header:
            fields = (["frame"] + self._csv_fields) if frame_id is not None else self._csv_fields
            csv.writer(f).writerow(fields)
        f.write(self.to_csv(frame_id=frame_id))

save_mot(path, frame_id=0)

Append track results in canonical MOT or corner-based MMOT format.

Parameters:

Name Type Description Default
path str | Path

File path to append to.

required
frame_id int

Frame index to serialize.

0
Source code in boxmot/trackers/results.py
def save_mot(self, path: str | Path, frame_id: int = 0) -> None:
    """Append track results in canonical MOT or corner-based MMOT format.

    Args:
        path: File path to append to.
        frame_id: Frame index to serialize.
    """
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    if not len(self):
        path.touch(exist_ok=True)
        return

    frame = np.full((len(self), 1), frame_id, dtype=np.float32)
    track_ids = self.id.reshape(-1, 1).astype(np.float32)
    confidence = self.conf.reshape(-1, 1).astype(np.float32)
    det_ind = self.det_ind.reshape(-1, 1).astype(np.float32)
    if self.is_obb:
        rows = np.column_stack((frame, track_ids, xywha_to_corners(self.xywha), confidence, self.cls, det_ind))
        fmt = "%d,%d," + ",".join(["%.6f"] * 9) + ",%d,%d"
    else:
        xyxy = self.xyxy
        ltwh = np.rint(
            np.column_stack((xyxy[:, 0], xyxy[:, 1], xyxy[:, 2] - xyxy[:, 0], xyxy[:, 3] - xyxy[:, 1]))
        ).astype(np.int32)
        rows = np.column_stack((frame, track_ids, ltwh, confidence, self.cls + 1, det_ind))
        fmt = "%d,%d,%d,%d,%d,%d,%.6f,%d,%d"
    with open(path, "a") as file:
        np.savetxt(file, rows, fmt=fmt)

summary()

Convert track results to a list of dictionaries.

Returns:

Type Description
list[dict[str, Any]]

list[dict]: One dict per track with keys: id, conf, cls, and either 'box' with x1/y1/x2/y2 (AABB) or cx/cy/w/h/angle (OBB).

Source code in boxmot/trackers/results.py
def summary(self) -> list[dict[str, Any]]:
    """Convert track results to a list of dictionaries.

    Returns:
        list[dict]: One dict per track with keys: id, conf, cls,
            and either 'box' with x1/y1/x2/y2 (AABB) or cx/cy/w/h/angle (OBB).
    """
    results = []
    for i in range(len(self)):
        entry: dict[str, Any] = {"id": int(self.id[i]), "conf": float(self.conf[i]), "cls": int(self.cls[i])}
        if self.is_obb:
            cx, cy, w, h, angle = self.xywha[i]
            entry["box"] = {"cx": float(cx), "cy": float(cy), "w": float(w), "h": float(h), "angle": float(angle)}
        else:
            x1, y1, x2, y2 = self.xyxy[i]
            entry["box"] = {"x1": float(x1), "y1": float(y1), "x2": float(x2), "y2": float(y2)}
        results.append(entry)
    return results

to_csv(frame_id=None)

Convert track results to CSV-formatted string.

Parameters:

Name Type Description Default
frame_id int | None

Optional frame number to include as the first column.

None

Returns:

Name Type Description
str str

CSV string with one row per track.

Source code in boxmot/trackers/results.py
def to_csv(self, frame_id: int | None = None) -> str:
    """Convert track results to CSV-formatted string.

    Args:
        frame_id: Optional frame number to include as the first column.

    Returns:
        str: CSV string with one row per track.
    """
    buf = io.StringIO()
    writer = csv.writer(buf)
    for i in range(len(self)):
        row = [frame_id] + self._row(i) if frame_id is not None else self._row(i)
        writer.writerow(row)
    return buf.getvalue()

to_json(indent=None)

Convert track results to a JSON string.

Parameters:

Name Type Description Default
indent int | None

JSON indentation level. None for compact output.

None

Returns:

Name Type Description
str str

JSON-encoded string of the track summaries.

Source code in boxmot/trackers/results.py
def to_json(self, indent: int | None = None) -> str:
    """Convert track results to a JSON string.

    Args:
        indent: JSON indentation level. None for compact output.

    Returns:
        str: JSON-encoded string of the track summaries.
    """
    return json.dumps(self.summary(), indent=indent)

transpose(*axes)

Transposition changes the row contract, so return a plain ndarray.

Source code in boxmot/trackers/results.py
def transpose(self, *axes) -> np.ndarray:
    """Transposition changes the row contract, so return a plain ndarray."""
    return np.asarray(self).transpose(*axes)