Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,15 @@ dependencies = [
"opencv-python>=4.8.0",
"rich>=13.0.0",
"requests>=2.28.0",
"click>=8.0",
]

[project.optional-dependencies]
detection = ["inference-models>=0.19.0"]
tune = ["optuna>=3.0.0"]

[project.scripts]
trackers = "trackers.scripts.__main__:main"
trackers = "trackers.cli.__main__:main"

[dependency-groups]
dev = [
Expand Down
5 changes: 5 additions & 0 deletions src/trackers/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# ------------------------------------------------------------------------
# Trackers
# Copyright (c) 2026 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
49 changes: 49 additions & 0 deletions src/trackers/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ------------------------------------------------------------------------
# Trackers
# Copyright (c) 2026 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------

from __future__ import annotations

import logging
import sys
import warnings

import click

from trackers.cli.download import download_command
from trackers.cli.eval import eval_command
from trackers.cli.track import track_command
from trackers.cli.tune import tune_command


@click.group(
context_settings={"help_option_names": ["-h", "--help"]},
)
@click.version_option(package_name="trackers", prog_name="trackers")
@click.option("-v", "--verbose", count=True, help="Increase log verbosity (-v INFO, -vv DEBUG).")
def cli(verbose: int) -> None:
"""Command-line tools for multi-object tracking."""
level = {0: logging.WARNING, 1: logging.INFO}.get(verbose, logging.DEBUG)
logging.basicConfig(level=level, format="%(message)s", handlers=[logging.StreamHandler(sys.stderr)])


cli.add_command(track_command, "track")
cli.add_command(eval_command, "eval")
cli.add_command(download_command, "download")
cli.add_command(tune_command, "tune")


def main() -> None:
"""Main entry point for the trackers CLI."""
warnings.warn(
"The trackers CLI is in beta. APIs may change in future releases.",
UserWarning,
stacklevel=2,
)
cli()


if __name__ == "__main__":
main()
107 changes: 107 additions & 0 deletions src/trackers/cli/_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# ------------------------------------------------------------------------
# Trackers
# Copyright (c) 2026 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------

from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from typing import TypeVar

import click

F = TypeVar("F", bound=Callable)

METRIC_CHOICES: list[str] = ["CLEAR", "HOTA", "Identity"]


def metrics_option(f: F) -> F:
"""Shared --metrics option for eval and tune commands.

Examples:
>>> import click
>>> @click.command()
... @metrics_option
... def cmd(metrics): pass
>>> cmd.params[0].name
'metrics'
"""
return click.option(
"--metrics",
multiple=True,
default=("CLEAR",),
type=click.Choice(METRIC_CHOICES),
help="Metrics to compute. Repeat flag for multiple: --metrics CLEAR --metrics HOTA. Default: CLEAR.",
)(f)


def threshold_option(f: F) -> F:
"""Shared --threshold option for eval and tune commands.

Examples:
>>> import click
>>> @click.command()
... @threshold_option
... def cmd(threshold): pass
>>> cmd.params[0].name
'threshold'
"""
return click.option(
"--threshold",
type=float,
default=0.5,
help="IoU threshold for CLEAR and Identity matching. Default: 0.5",
)(f)


def seqmap_option(f: F) -> F:
"""Shared --seqmap option for eval and tune commands.

Examples:
>>> import click
>>> @click.command()
... @seqmap_option
... def cmd(seqmap): pass
>>> cmd.params[0].name
'seqmap'
"""
return click.option(
"--seqmap",
type=click.Path(path_type=Path),
default=None,
metavar="PATH",
help="Sequence map file listing sequences to evaluate.",
)(f)


def output_option(help_text: str = "Output file path.") -> Callable[[F], F]:
"""Shared -o/--output option factory.

Args:
help_text: Help text for the option.

Returns:
Decorator that adds the output option to a command.

Examples:
>>> import click
>>> @click.command()
... @output_option("Output JSON file.")
... def cmd(output): pass
>>> cmd.params[0].name
'output'
"""

def decorator(f: F) -> F:
return click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
metavar="PATH",
help=help_text,
)(f)

return decorator
87 changes: 87 additions & 0 deletions src/trackers/cli/download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# ------------------------------------------------------------------------
# Trackers
# Copyright (c) 2026 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------

from __future__ import annotations

import click
from rich.console import Console
from rich.panel import Panel

from trackers.datasets.download import _DEFAULT_CACHE_DIR, _DEFAULT_OUTPUT_DIR
from trackers.datasets.manifest import _DATASETS


@click.command("download")
@click.argument("dataset", required=False, default=None)
@click.option("--list", "show_list", is_flag=True, help="List available datasets, splits, and asset types.")
@click.option(
"--split",
default=None,
help="Comma-separated splits to download (e.g. train,val,test). If omitted, all available splits are downloaded.",
)
@click.option(
"--asset",
default=None,
help=(
"Comma-separated assets to download: annotations,frames,detections."
" If omitted, all available assets are downloaded."
),
)
@click.option("-o", "--output", default=_DEFAULT_OUTPUT_DIR, help="Output directory (default: current directory).")
@click.option(
"--cache-dir",
"cache_dir",
default=_DEFAULT_CACHE_DIR,
help="Cache directory for downloaded ZIPs (default: ~/.cache/trackers).",
)
def download_command(
dataset: str | None,
show_list: bool,
split: str | None,
asset: str | None,
output: str,
cache_dir: str,
) -> None:
"""Download benchmark tracking datasets."""
if show_list:
_print_available()
return

if not dataset:
raise click.UsageError("Please specify a dataset name or use --list.")

from trackers.datasets.download import download_dataset

split_list = [s.strip() for s in split.split(",")] if split else None
asset_list = [a.strip() for a in asset.split(",")] if asset else None

try:
download_dataset(
dataset=dataset,
split=split_list,
asset=asset_list,
output=output,
cache_dir=cache_dir,
)
except Exception as e:
raise click.ClickException(str(e)) from e


def _print_available() -> None:
"""Print available datasets, splits, and asset types."""
console = Console()
for name, dataset_info in _DATASETS.items():
description = dataset_info.get("description", "")
splits_dict: dict[str, dict] = dataset_info.get("splits", {})

max_split_len = max(len(s) for s in splits_dict) if splits_dict else 0
split_lines = [
f"{split:<{max_split_len}} {', '.join(assets.keys())}" for split, assets in splits_dict.items()
]

body = f"{description}\n\n" + "\n".join(split_lines)
console.print(Panel(body, title=name.value, title_align="left"))
console.print()
111 changes: 111 additions & 0 deletions src/trackers/cli/eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ------------------------------------------------------------------------
# Trackers
# Copyright (c) 2026 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------

from __future__ import annotations

from pathlib import Path
from typing import cast

import click

from trackers.cli._options import metrics_option, output_option, seqmap_option, threshold_option


@click.command("eval")
@click.option(
"--gt",
type=click.Path(path_type=Path),
default=None,
metavar="PATH",
help="Path to ground truth file (MOT format).",
)
@click.option(
"--tracker",
"tracker_path",
type=click.Path(path_type=Path),
default=None,
metavar="PATH",
help="Path to tracker predictions file (MOT format).",
)
@click.option(
"--gt-dir",
type=click.Path(path_type=Path),
default=None,
metavar="DIR",
help="Directory containing ground truth files.",
)
@click.option(
"--tracker-dir",
type=click.Path(path_type=Path),
default=None,
metavar="DIR",
help="Directory containing tracker prediction files.",
)
@seqmap_option
@metrics_option
@threshold_option
@click.option(
"--columns", multiple=True, default=(), metavar="COL", help="Metric columns to display. Default: auto-selected."
)
@output_option("Output file for results (JSON format).")
def eval_command(
gt: Path | None,
tracker_path: Path | None,
gt_dir: Path | None,
tracker_dir: Path | None,
seqmap: Path | None,
metrics: tuple[str, ...],
threshold: float,
columns: tuple[str, ...],
output: Path | None,
) -> None:
"""Evaluate tracker predictions against ground truth."""
single_mode = gt is not None and tracker_path is not None
benchmark_mode = gt_dir is not None and tracker_dir is not None

if not single_mode and not benchmark_mode:
raise click.UsageError("Must specify either --gt/--tracker or --gt-dir/--tracker-dir")

if single_mode and benchmark_mode:
raise click.UsageError("Cannot use both single sequence and benchmark mode")

columns_list: list[str] | None = list(columns) if columns else None
metrics_list = list(metrics)

from trackers.eval import evaluate_mot_sequence, evaluate_mot_sequences

try:
if single_mode:
seq_result = evaluate_mot_sequence(
gt_path=cast(Path, gt),
tracker_path=cast(Path, tracker_path),
metrics=metrics_list,
threshold=threshold,
)
print(seq_result.table(columns=columns_list))

if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(seq_result.json())
print(f"\nResults saved to: {output}")
else:
bench_result = evaluate_mot_sequences(
gt_dir=cast(Path, gt_dir),
tracker_dir=cast(Path, tracker_dir),
seqmap=seqmap,
metrics=metrics_list,
threshold=threshold,
)
print(bench_result.table(columns=columns_list))

if output:
bench_result.save(output)
print(f"\nResults saved to: {output}")

except FileNotFoundError as e:
raise click.ClickException(str(e)) from e
except ValueError as e:
raise click.ClickException(str(e)) from e
Loading
Loading