diff --git a/docs/index.md b/docs/index.md index 80d354d4..e3f3e4f7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,11 +41,11 @@ Point at a video, webcam, RTSP stream, or image directory. Get tracked output. ```bash trackers track \ --source video.mp4 \ - --output output.mp4 \ - --model rfdetr-medium \ + --out.output output.mp4 \ + --detection.model rfdetr-medium \ --tracker bytetrack \ - --show-labels \ - --show-trajectories + --show.labels \ + --show.trajectories ``` For all CLI options, see the [tracking guide](learn/track.md). diff --git a/docs/javascripts/command_builder.js b/docs/javascripts/command_builder.js index ef1b5dd4..d1dd0e0c 100644 --- a/docs/javascripts/command_builder.js +++ b/docs/javascripts/command_builder.js @@ -80,17 +80,17 @@ document.addEventListener("DOMContentLoaded", function () { } const prefix = state.modelType === "segmentation" ? "rfdetr-seg-" : "rfdetr-"; - parts.push(`--model ${prefix}${state.modelSize}`); + parts.push(`--detection.model ${prefix}${state.modelSize}`); if (state.showModelOptions) { if (state.confidence !== defaults.confidence && isValidDecimal01(state.confidence, 0.05)) { - parts.push(`--model.confidence ${state.confidence}`); + parts.push(`--detection.confidence ${state.confidence}`); } if (state.device !== "auto") { - parts.push(`--model.device ${state.device}`); + parts.push(`--detection.device ${state.device}`); } if (state.classes && isValidClasses(state.classes)) { - parts.push(`--classes ${state.classes}`); + parts.push(`--filters.classes ${state.classes}`); } } @@ -120,19 +120,19 @@ document.addEventListener("DOMContentLoaded", function () { } } - if (state.display) parts.push("--display"); - if (!state.showBoxes) parts.push("--no-boxes"); - if (state.showMasks) parts.push("--show-masks"); - if (state.showConfidence) parts.push("--show-confidence"); - if (state.showLabels) parts.push("--show-labels"); - if (!state.showIds) parts.push("--no-ids"); - if (state.showTrajectories) parts.push("--show-trajectories"); + if (state.display) parts.push("--show.display"); + if (!state.showBoxes) parts.push("--no-show-boxes"); + if (state.showMasks) parts.push("--show.masks"); + if (state.showConfidence) parts.push("--show.confidence"); + if (state.showLabels) parts.push("--show.labels"); + if (!state.showIds) parts.push("--no-show-ids"); + if (state.showTrajectories) parts.push("--show.trajectories"); const outputValue = state.output.trim(); if (outputValue) { - parts.push(`--output ${outputValue}`); + parts.push(`--out.output ${outputValue}`); if (state.overwrite) { - parts.push("--overwrite"); + parts.push("--out.overwrite"); } } diff --git a/docs/learn/detection-quality.md b/docs/learn/detection-quality.md index 4ea4a909..d395f0ec 100644 --- a/docs/learn/detection-quality.md +++ b/docs/learn/detection-quality.md @@ -66,10 +66,10 @@ Run ByteTrack with default parameters three times, changing only the detection m ```bash trackers track \ --source ./data/mot17/val/MOT17-13-FRCNN/img1 \ - --model yolo26n-640 \ + --detection.model yolo26n-640 \ --tracker bytetrack \ - --classes person \ - --mot-output results/yolo26n/MOT17-13-FRCNN.txt + --filters.classes person \ + --out.mot-results results/yolo26n/MOT17-13-FRCNN.txt ``` === "All sequences" @@ -78,10 +78,10 @@ Run ByteTrack with default parameters three times, changing only the detection m for seq in MOT17-02-FRCNN MOT17-04-FRCNN MOT17-05-FRCNN MOT17-09-FRCNN MOT17-10-FRCNN MOT17-11-FRCNN MOT17-13-FRCNN; do trackers track \ --source ./data/mot17/val/$seq/img1 \ - --model yolo26n-640 \ + --detection.model yolo26n-640 \ --tracker bytetrack \ - --classes person \ - --mot-output results/yolo26n/$seq.txt + --filters.classes person \ + --out.mot-results results/yolo26n/$seq.txt done ``` @@ -97,10 +97,10 @@ Run ByteTrack with default parameters three times, changing only the detection m ```bash trackers track \ --source ./data/mot17/val/MOT17-13-FRCNN/img1 \ - --model rfdetr-nano \ + --detection.model rfdetr-nano \ --tracker bytetrack \ - --classes person \ - --mot-output results/rfdetr-nano/MOT17-13-FRCNN.txt + --filters.classes person \ + --out.mot-results results/rfdetr-nano/MOT17-13-FRCNN.txt ``` === "All sequences" @@ -109,10 +109,10 @@ Run ByteTrack with default parameters three times, changing only the detection m for seq in MOT17-02-FRCNN MOT17-04-FRCNN MOT17-05-FRCNN MOT17-09-FRCNN MOT17-10-FRCNN MOT17-11-FRCNN MOT17-13-FRCNN; do trackers track \ --source ./data/mot17/val/$seq/img1 \ - --model rfdetr-nano \ + --detection.model rfdetr-nano \ --tracker bytetrack \ - --classes person \ - --mot-output results/rfdetr-nano/$seq.txt + --filters.classes person \ + --out.mot-results results/rfdetr-nano/$seq.txt done ``` @@ -128,10 +128,10 @@ Run ByteTrack with default parameters three times, changing only the detection m ```bash trackers track \ --source ./data/mot17/val/MOT17-13-FRCNN/img1 \ - --model rfdetr-medium \ + --detection.model rfdetr-medium \ --tracker bytetrack \ - --classes person \ - --mot-output results/rfdetr-medium/MOT17-13-FRCNN.txt + --filters.classes person \ + --out.mot-results results/rfdetr-medium/MOT17-13-FRCNN.txt ``` === "All sequences" @@ -140,10 +140,10 @@ Run ByteTrack with default parameters three times, changing only the detection m for seq in MOT17-02-FRCNN MOT17-04-FRCNN MOT17-05-FRCNN MOT17-09-FRCNN MOT17-10-FRCNN MOT17-11-FRCNN MOT17-13-FRCNN; do trackers track \ --source ./data/mot17/val/$seq/img1 \ - --model rfdetr-medium \ + --detection.model rfdetr-medium \ --tracker bytetrack \ - --classes person \ - --mot-output results/rfdetr-medium/$seq.txt + --filters.classes person \ + --out.mot-results results/rfdetr-medium/$seq.txt done ``` diff --git a/docs/learn/evaluate.md b/docs/learn/evaluate.md index 45cd8eca..27927283 100644 --- a/docs/learn/evaluate.md +++ b/docs/learn/evaluate.md @@ -89,13 +89,13 @@ For more download options, see the [download guide](download.md). Feed the pre-computed detections into a tracker and write the results to a file for evaluation. -Pass `--detections` to provide input detections and `--mot-output` to save the tracker output in MOT format. +Pass `--detections` to provide input detections and `--out.mot-results` to save the tracker output in MOT format. ```text trackers track \ --detections ./data/mot17/val/MOT17-02-FRCNN/det/det.txt \ --tracker bytetrack \ - --mot-output results/MOT17-02-FRCNN.txt + --out.mot-results results/MOT17-02-FRCNN.txt ``` --- diff --git a/docs/learn/track.md b/docs/learn/track.md index 57027705..96134a36 100644 --- a/docs/learn/track.md +++ b/docs/learn/track.md @@ -49,7 +49,7 @@ Read frames from video files, webcams, RTSP streams, or image directories. Each Track objects with one command. Uses RF-DETR Nano and ByteTrack by default. ```text - trackers track --source source.mp4 --output output.mp4 + trackers track --source source.mp4 --out.output output.mp4 ``` === "Python" @@ -93,14 +93,14 @@ Trackers assign stable IDs to detections across frames, maintaining object ident === "CLI" - Select a tracker with `--tracker` and tune its behavior with `--tracker.*` arguments. + Select a tracker with `--tracker` and tune its behavior with per-parameter `--tracker.` flags. ```text trackers track \ --source source.mp4 \ --tracker bytetrack \ - --tracker.lost_track_buffer 60 \ - --tracker.minimum_consecutive_frames 5 + --tracker.lost-track-buffer 60 \ + --tracker.minimum-consecutive-frames 5 ``` === "Python" @@ -139,15 +139,15 @@ Trackers don't detect objects—they link detections across frames. A detection === "CLI" - Configure detection with `--model.*` arguments. Filter by confidence and class before tracking. + Configure detection with `--detection.*` arguments. Filter by confidence and class before tracking. ```text trackers track \ --source source.mp4 \ - --model rfdetr-medium \ - --model.confidence 0.3 \ - --model.device cuda \ - --classes person,car + --detection.model rfdetr-medium \ + --detection.confidence 0.3 \ + --detection.device cuda \ + --filters.classes person,car ``` === "Python" @@ -188,10 +188,10 @@ Visualization renders tracking results for debugging, demos, and qualitative eva ```text trackers track \ --source source.mp4 \ - --display \ - --show-labels \ - --show-confidence \ - --show-trajectories + --show.display \ + --show.labels \ + --show.confidence \ + --show.trajectories ``` === "Python" @@ -274,7 +274,7 @@ Save tracking results as annotated video files or display them in real time. Specify an output path to save annotated video. ```text - trackers track --source source.mp4 --output output.mp4 --overwrite + trackers track --source source.mp4 --out.output output.mp4 --out.overwrite ``` === "Python" @@ -342,97 +342,120 @@ All arguments accepted by the `trackers track` command. — - --output + --out.output Path for output video. If a directory is given, saves as output.mp4 inside it. none - --overwrite + --out.overwrite Allow overwriting existing output files. Without this flag, existing files cause an error. false - --model + --out.mot-results + Output path for tracker results in MOT format (per-frame detections with IDs). + none + + + --detections + Path to a pre-computed MOT-format detections file. When set, skips the detection model and feeds detections directly to the tracker. Mutually exclusive with --detection.model. + — + + + --detection.model Model identifier. Pretrained: rfdetr-nano, rfdetr-small, rfdetr-medium, rfdetr-large. Segmentation: rfdetr-seg-*. rfdetr-nano - --model.confidence + --detection.confidence Minimum confidence threshold. Lower values increase recall but may add noise. 0.5 - --model.device + --detection.device Compute device. Options: auto, cpu, cuda, cuda:0, mps. auto - --model.api_key + --detection.api-key Roboflow API key for custom hosted models. none - --classes + --filters.classes Comma-separated class names or IDs to track. Example: person,car or 0,2. all + + --filters.track-ids + Comma-separated track IDs to keep in output. Example: 1,3,5. + all + --tracker Tracking algorithm. Options: bytetrack, sort, ocsort, botsort. bytetrack - --tracker.lost_track_buffer - Frames to retain a track without detections. Higher values improve occlusion handling but risk ID drift. - 30 + --tracker.lost-track-buffer + Number of frames a lost track is kept before deletion. Applies to all trackers. + tracker default + + + --tracker.frame-rate + Source frame rate used by the tracker for time-based logic. Applies to all trackers. + tracker default + + + --tracker.track-activation-threshold + Detection confidence required to start a new track. Applies to bytetrack, sort, botsort. + tracker default - --tracker.track_activation_threshold - Minimum confidence to start a new track. Lower values catch more objects but increase false positives. - 0.25 + --tracker.minimum-consecutive-frames + Frames a new track must be matched before being confirmed. Applies to all trackers. + tracker default - --tracker.minimum_consecutive_frames - Consecutive detections required before a track is confirmed. Suppresses spurious detections. - 3 + --tracker.minimum-iou-threshold + IoU threshold for detection-to-track association. Applies to bytetrack, sort, ocsort. + tracker default - --tracker.minimum_iou_threshold - Minimum IoU overlap to match a detection to an existing track. Higher values require tighter alignment. - 0.3 + Algorithm-specific flags also exist (e.g. --tracker.high-conf-det-threshold, --tracker.enable-cmc/--tracker.no-enable-cmc, --tracker.cmc-method, --tracker.delta-t); each only takes effect when the selected --tracker exposes that parameter. Unset flags fall back to the tracker's own defaults. - --display + --show.display Opens a live preview window. Press q or ESC to quit. false - --show-boxes + --show.boxes Draw bounding boxes around tracked objects. true - --show-masks + --show.masks Draw segmentation masks. Only available with rfdetr-seg-* models. false - --show-confidence + --show.confidence Show detection confidence scores in labels. false - --show-labels + --show.labels Show class names in labels. false - --show-ids + --show.ids Show tracker IDs in labels. true - --show-trajectories + --show.trajectories Draw motion trails showing recent positions of each track. false diff --git a/pyproject.toml b/pyproject.toml index b010f719..6afad8a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "opencv-python>=4.8.0", "rich>=13.0.0", "requests>=2.28.0", + "defopt>=7.0.0,<8", ] [project.optional-dependencies] @@ -48,7 +49,7 @@ 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 = [ @@ -57,6 +58,7 @@ dev = [ "pre-commit>=4.2.0", "torch", "torchvision", + "mypy>=2.1.0", ] docs = [ "mkdocs>=1.6.1", @@ -207,5 +209,7 @@ module = [ "firerequests", "scipy", "scipy.*", + "inference_models", + "defopt", ] ignore_missing_imports = true diff --git a/src/trackers/cli/__init__.py b/src/trackers/cli/__init__.py new file mode 100644 index 00000000..57226e88 --- /dev/null +++ b/src/trackers/cli/__init__.py @@ -0,0 +1,5 @@ +# ------------------------------------------------------------------------ +# Trackers +# Copyright (c) 2026 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ diff --git a/src/trackers/cli/__main__.py b/src/trackers/cli/__main__.py new file mode 100644 index 00000000..13db16f7 --- /dev/null +++ b/src/trackers/cli/__main__.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# ------------------------------------------------------------------------ +# 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 json +import sys +import warnings +from argparse import Action, ArgumentParser + +import defopt as _defopt + +# Top-level argument groups that should be rewritten from ``--prefix-name`` +# to dotted form ``--prefix.name`` on the generated argparse parser. Groups +# are derived from the leading underscore-separated token of each parameter +# in ``track()`` (e.g. ``detection_confidence`` → group ``detection``). +_GROUPS = frozenset({"detection", "filters", "out", "show", "tracker"}) + +_orig_create_parser = _defopt._create_parser + + +def _dotted_create_parser(funcs: object, opts: object) -> ArgumentParser: + """Wrap ``defopt._create_parser`` and rewrite group flags to dotted form. + + Args: + funcs: Mapping of subcommand name to callable (passed through to + ``defopt._create_parser``). + opts: ``defopt`` options instance (passed through unchanged). + + Returns: + The argparse ``ArgumentParser`` returned by ``defopt._create_parser``, + with every option string of the form ``---`` (for any + group in ``_GROUPS``) rewritten to ``--.``. + """ + parser = _orig_create_parser(funcs, opts) + _rewrite_dotted(parser) + return parser + + +def _rewrite_dotted(parser: ArgumentParser) -> None: + """Rewrite ``--prefix-name`` options to ``--prefix.name`` in-place. + + Handles both positive flags (``--show-ids`` → ``--show.ids``) and boolean + negation flags (``--no-show-ids`` → ``--show.no-ids``). Also updates + ``action.negative_option_strings`` so ``_BooleanOptionalAction`` True/False + detection keeps working after the rename. + + Recurses into subparsers so all subcommands are rewritten. + + Args: + parser: An argparse parser (root or subparser) to rewrite. + """ + new_map: dict[str, Action] = {} + for opt, action in list(parser._option_string_actions.items()): + if not opt.startswith("--"): + continue + bare = opt[2:] + new_opt: str | None = None + if bare.startswith("no-"): + # Negation: --no-- → --.no- + after_no = bare[3:] + prefix, sep, rest = after_no.partition("-") + if sep and prefix in _GROUPS and rest: + new_opt = f"--{prefix}.no-{rest}" + else: + # Positive: --- → --. + prefix, sep, rest = bare.partition("-") + if sep and prefix in _GROUPS and rest: + new_opt = f"--{prefix}.{rest}" + if new_opt is not None: + action.option_strings = [new_opt if s == opt else s for s in action.option_strings] + if hasattr(action, "negative_option_strings"): + action.negative_option_strings = [new_opt if s == opt else s for s in action.negative_option_strings] + new_map[new_opt] = action + del parser._option_string_actions[opt] + parser._option_string_actions.update(new_map) + for action in parser._actions: + if hasattr(action, "_name_parser_map"): + for sub in action._name_parser_map.values(): # type: ignore[attr-defined] + _rewrite_dotted(sub) + + +_defopt._create_parser = _dotted_create_parser + + +def main() -> int: + """Main entry point for the trackers CLI.""" + warnings.warn( + "The trackers CLI is in beta. APIs may change in future releases.", + UserWarning, + stacklevel=2, + ) + + from importlib.metadata import version + + from trackers.cli.download import download + from trackers.cli.eval import eval_cmd + from trackers.cli.track import track + from trackers.cli.tune import tune + + result = _defopt.run( + {"track": track, "eval": eval_cmd, "tune": tune, "download": download}, + argv=sys.argv[1:], + cli_options="all", + parsers={dict: json.loads}, + version=version("trackers"), + short={"out-output": "o"}, + ) + return result if isinstance(result, int) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/trackers/cli/download.py b/src/trackers/cli/download.py new file mode 100644 index 00000000..6a55a68a --- /dev/null +++ b/src/trackers/cli/download.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# ------------------------------------------------------------------------ +# 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 sys + +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 + + +def download( + dataset: str = "", + list_available: bool = False, + split: str = "", + asset: str = "", + output: str = _DEFAULT_OUTPUT_DIR, + cache_dir: str = _DEFAULT_CACHE_DIR, +) -> int: + """Download benchmark tracking datasets. + + Args: + dataset: Dataset name (e.g. mot17, sportsmot). + list_available: List available datasets, splits, and asset types. + split: Comma-separated splits to download (e.g. train,val,test). + asset: Comma-separated assets to download (e.g. annotations,frames,detections). + output: Output directory. + cache_dir: Cache directory for downloaded ZIPs. + + Returns: + Exit code: 0 on success, 1 on error. + + Examples: + >>> download() == 1 + True + """ + if list_available: + _print_available() + return 0 + + if not dataset: + print("Please specify a dataset name or use --list.", file=sys.stderr) + return 1 + + 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: + print(f"Error: {e}", file=sys.stderr) + return 1 + + return 0 + + +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() diff --git a/src/trackers/cli/eval.py b/src/trackers/cli/eval.py new file mode 100644 index 00000000..35968480 --- /dev/null +++ b/src/trackers/cli/eval.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# ------------------------------------------------------------------------ +# 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 +from pathlib import Path + + +def eval_cmd( + gt: Path | None = None, + tracker: Path | None = None, + gt_dir: Path | None = None, + tracker_dir: Path | None = None, + seqmap: Path | None = None, + metrics: list[str] | None = None, + threshold: float = 0.5, + columns: list[str] | None = None, + output: Path | None = None, +) -> int: + """Evaluate tracker predictions against ground truth. + + Args: + gt: Path to ground truth file (MOT format). Single-sequence mode. + tracker: Path to tracker predictions file (MOT format). Single-sequence mode. + gt_dir: Directory containing ground truth files. Benchmark mode. + tracker_dir: Directory containing tracker prediction files. Benchmark mode. + seqmap: Sequence map file listing sequences to evaluate. + metrics: Metrics to compute (CLEAR, HOTA, Identity). Default: CLEAR. + threshold: IoU threshold for CLEAR and Identity matching. + columns: Metric columns to display (default: auto-selected based on metrics). + output: Output file for results (JSON format). + + Returns: + Exit code: 0 on success, 1 on error. + + Examples: + >>> eval_cmd() == 1 + True + """ + logging.basicConfig( + level=logging.INFO, + format="%(message)s", + handlers=[logging.StreamHandler(sys.stderr)], + ) + + if metrics is None: + metrics = ["CLEAR"] + + single_mode = gt is not None and tracker is not None + benchmark_mode = gt_dir is not None and tracker_dir is not None + + if not single_mode and not benchmark_mode: + print( + "Error: Must specify either --gt/--tracker or --gt-dir/--tracker-dir", + file=sys.stderr, + ) + return 1 + + if single_mode and benchmark_mode: + print( + "Error: Cannot use both single sequence and benchmark mode", + file=sys.stderr, + ) + return 1 + + from trackers.eval import evaluate_mot_sequence, evaluate_mot_sequences + + try: + if single_mode and gt is not None and tracker is not None: + seq_result = evaluate_mot_sequence( + gt_path=gt, + tracker_path=tracker, + metrics=metrics, + threshold=threshold, + ) + print(seq_result.table(columns=columns)) + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(seq_result.json()) + print(f"\nResults saved to: {output}") + elif gt_dir is not None and tracker_dir is not None: + bench_result = evaluate_mot_sequences( + gt_dir=gt_dir, + tracker_dir=tracker_dir, + seqmap=seqmap, + metrics=metrics, + threshold=threshold, + ) + print(bench_result.table(columns=columns)) + if output: + bench_result.save(output) + print(f"\nResults saved to: {output}") + + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + return 0 diff --git a/src/trackers/cli/progress.py b/src/trackers/cli/progress.py new file mode 100644 index 00000000..67de2608 --- /dev/null +++ b/src/trackers/cli/progress.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# ------------------------------------------------------------------------ +# 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 itertools +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +import cv2 +from rich.console import Console +from rich.live import Live +from rich.text import Text + +from trackers.io.video import IMAGE_EXTENSIONS + +_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" +_STREAM_PREFIXES = ("rtsp://", "http://", "https://") +_ICON_OK = "✓" +_ICON_FAIL = "✗" + + +@dataclass +class _SourceInfo: + """Metadata about a frame source used to drive progress display. + + Attributes: + source_type: Kind of source (`video`, `image_dir`, `webcam`, + `stream`). + total_frames: Total frame count when known, `None` for unbounded + sources such as webcams and network streams. + fps: Source frame-rate when known, `None` otherwise. + """ + + source_type: Literal["video", "image_dir", "webcam", "stream"] + total_frames: int | None = None + fps: float | None = None + + +def _classify_source(source: str | Path | int) -> _SourceInfo: + """Classify a frame source and extract metadata. + + The function inspects *source* without consuming any frames so it can be + called before the main processing loop. + + Args: + source: The same value accepted by `frames_from_source`. + + Returns: + A `_SourceInfo` describing the source. + """ + if isinstance(source, int) or (isinstance(source, str) and source.isdigit()): + return _SourceInfo(source_type="webcam") + + source_str = str(source) + + if any(source_str.lower().startswith(p) for p in _STREAM_PREFIXES): + return _SourceInfo(source_type="stream") + + path = Path(source_str) + if path.is_dir(): + count = sum(1 for p in path.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS) + return _SourceInfo( + source_type="image_dir", + total_frames=count if count > 0 else None, + ) + + cap = cv2.VideoCapture(source_str) + if not cap.isOpened(): + # Cannot open; still classify as video - the real error will come + # from frames_from_source later. + return _SourceInfo(source_type="video") + + try: + total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + fps = cap.get(cv2.CAP_PROP_FPS) + return _SourceInfo( + source_type="video", + total_frames=total if total > 0 else None, + fps=fps if fps and fps > 0 else None, + ) + finally: + cap.release() + + +def _format_time(seconds: float) -> str: + """Format `seconds` as `H:MM:SS` or `M:SS`.""" + if seconds < 0: + return "--" + minutes, seconds_remainder = divmod(int(seconds), 60) + hours, minutes = divmod(minutes, 60) + if hours > 0: + return f"{hours}:{minutes:02d}:{seconds_remainder:02d}" + return f"{minutes}:{seconds_remainder:02d}" + + +class _TrackingProgress: + """Context-manager that renders a single live progress line. + + Args: + source_info: Source metadata returned by `_classify_source`. + console: Optional `Console` instance (useful for testing with a + `StringIO` file). + """ + + def __init__( + self, + source_info: _SourceInfo, + console: Console | None = None, + ) -> None: + self._source_info = source_info + self._console = console or Console() + self._frames_processed = 0 + self._start_time: float = 0.0 + self._spinner = itertools.cycle(_SPINNER_FRAMES) + self._live: Live | None = None + self._interrupted = False + + def update(self) -> None: + """Record one processed frame and refresh the display.""" + self._frames_processed += 1 + icon = next(self._spinner) + if self._live is not None: + self._live.update(self._build_line(icon)) + + def complete(self, *, interrupted: bool = False) -> None: + """Signal that the processing loop has ended. + + Must be called before leaving the `with` block so that `__exit__` + can render the correct final state. + + Args: + interrupted: `True` when the loop was terminated early (e.g. + display-quit). + """ + self._interrupted = interrupted + + def __enter__(self) -> _TrackingProgress: + self._start_time = time.monotonic() + self._live = Live( + self._build_line("⠋"), + console=self._console, + refresh_per_second=12, + transient=True, + ) + self._live.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + if self._live is not None: + self._live.__exit__(None, None, None) + + icon, suffix = self._resolve_final_state(exc_type) + final = self._build_line(icon, show_eta=False, suffix=suffix) + self._console.print(final) + + @property + def _is_bounded(self) -> bool: + """Whether the source has a known total frame count.""" + return self._source_info.total_frames is not None + + def _resolve_final_state(self, exc_type: type[BaseException] | None) -> tuple[str, str]: + """Return `(icon, suffix)` for the final printed line.""" + is_real_error = exc_type is not None and not issubclass(exc_type, KeyboardInterrupt) + + if is_real_error: + return (_ICON_FAIL, "(source lost)") + + was_stopped_early = exc_type is not None or self._interrupted + + if was_stopped_early and self._is_bounded: + return (_ICON_FAIL, "(interrupted)") + + return (_ICON_OK, "") + + def _build_line( + self, + icon: str, + *, + show_eta: bool = True, + suffix: str = "", + ) -> Text: + """Compose the single-line progress string.""" + elapsed = time.monotonic() - self._start_time + fps = self._frames_processed / elapsed if elapsed > 0 else 0.0 + total = self._source_info.total_frames + + if total is not None: + total_str = str(total) + frames_part = f"{self._frames_processed:>{len(total_str)}} / {total_str}" + else: + frames_part = f"{self._frames_processed} / --" + + if total is not None and total > 0: + percentage = self._frames_processed / total * 100 + percentage_part = f"{percentage:>3.0f}%" + else: + percentage_part = " --" + + fps_part = f"{fps:>.1f} fps" + elapsed_part = f"{_format_time(elapsed)} elapsed" + + parts = [ + f"{icon} Tracking", + f"{frames_part} frames", + percentage_part, + fps_part, + elapsed_part, + ] + + if show_eta: + if total is not None and fps > 0: + remaining = (total - self._frames_processed) / fps + parts.append(f"eta {_format_time(remaining)}") + else: + parts.append("eta --") + + if suffix: + parts.append(suffix) + + return Text(" ".join(parts)) diff --git a/src/trackers/cli/track.py b/src/trackers/cli/track.py new file mode 100644 index 00000000..d1756412 --- /dev/null +++ b/src/trackers/cli/track.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python +# ------------------------------------------------------------------------ +# 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 sys +from contextlib import nullcontext +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +import supervision as sv + +from trackers import frames_from_source +from trackers.cli.progress import _classify_source, _SourceInfo, _TrackingProgress +from trackers.core.base import BaseTracker +from trackers.io.mot import _mot_frame_to_detections, _MOTOutput, load_mot_file +from trackers.io.paths import _resolve_video_output_path, _validate_output_path +from trackers.io.video import _DEFAULT_OUTPUT_FPS, _DisplayWindow, _VideoOutput +from trackers.utils.device import _best_device + +if TYPE_CHECKING: + from inference_models import AnyModel + +# Defaults +DEFAULT_MODEL = "rfdetr-nano" +DEFAULT_TRACKER = "bytetrack" +DEFAULT_CONFIDENCE = 0.5 +DEFAULT_DEVICE = "auto" + +# Visualization +COLOR_PALETTE = sv.ColorPalette.from_hex( + [ + "#ffff00", + "#ff9b00", + "#ff8080", + "#ff66b2", + "#ff66ff", + "#b266ff", + "#9999ff", + "#3399ff", + "#66ffff", + "#33ff99", + "#66ff66", + "#99ff00", + ] +) + + +def track( + source: str | None = None, + detection_model: str | None = None, + detections: Path | None = None, + detection_confidence: float = DEFAULT_CONFIDENCE, + detection_device: str = DEFAULT_DEVICE, + detection_api_key: str | None = None, + filters_classes: str | None = None, + filters_track_ids: str | None = None, + tracker: str = DEFAULT_TRACKER, + tracker_lost_track_buffer: int | None = None, + tracker_frame_rate: float | None = None, + tracker_track_activation_threshold: float | None = None, + tracker_minimum_consecutive_frames: int | None = None, + tracker_minimum_iou_threshold: float | None = None, + tracker_high_conf_det_threshold: float | None = None, + tracker_minimum_iou_threshold_first_assoc: float | None = None, + tracker_minimum_iou_threshold_second_assoc: float | None = None, + tracker_minimum_iou_threshold_unconfirmed_assoc: float | None = None, + tracker_enable_cmc: bool | None = None, + tracker_cmc_method: str | None = None, + tracker_cmc_downscale: int | None = None, + tracker_instant_first_frame_activation: bool | None = None, + tracker_direction_consistency_weight: float | None = None, + tracker_delta_t: int | None = None, + out_output: Path | None = None, + out_mot_results: Path | None = None, + out_overwrite: bool = False, + show_display: bool = False, + show_boxes: bool = True, + show_masks: bool = False, + show_labels: bool = False, + show_ids: bool = True, + show_confidence: bool = False, + show_trajectories: bool = False, +) -> int: + """Track objects in video using detection and tracking. + + Tracker-specific parameters are exposed as ``--tracker.`` flags. + Each flag defaults to ``None`` (meaning "use the tracker's own default"); + only flags the user supplies are forwarded to the tracker constructor. + + Args: + source: Video file, webcam index (0), RTSP URL, or image directory. + detection_model: Model ID for detection (e.g. rfdetr-nano, rfdetr-base, + workspace/project/version). Default: rfdetr-nano. + detections: Load pre-computed detections from MOT format file (mutually exclusive with detection model). + detection_confidence: Detection confidence threshold. + detection_device: Device to run model on (auto, cpu, cuda, cuda:0, mps). + detection_api_key: Roboflow API key for custom models. + filters_classes: Filter by class names or IDs (comma-separated, e.g. person,car). + filters_track_ids: Filter output by track IDs (comma-separated, e.g. 1,3,5). + tracker: Tracking algorithm ID (``bytetrack``, ``sort``, ``ocsort``, ``botsort``). + tracker_lost_track_buffer: Frames a lost track is kept before deletion. Common to all trackers. + tracker_frame_rate: Source frame rate used by the tracker for time-based logic. Common to all trackers. + tracker_track_activation_threshold: Detection confidence to start a new track. Applies to ``bytetrack``, + ``sort``, ``botsort``. + tracker_minimum_consecutive_frames: Frames a new track must be matched before being confirmed. Common to + all trackers. + tracker_minimum_iou_threshold: IoU threshold for association. Applies to ``bytetrack``, ``sort``, ``ocsort``. + tracker_high_conf_det_threshold: High-confidence detection threshold for the first association pass. + Applies to ``bytetrack``, ``ocsort``, ``botsort``. + tracker_minimum_iou_threshold_first_assoc: IoU threshold for the first association pass. Applies to + ``botsort`` only. + tracker_minimum_iou_threshold_second_assoc: IoU threshold for the second association pass. Applies to + ``botsort`` only. + tracker_minimum_iou_threshold_unconfirmed_assoc: IoU threshold for unconfirmed-track association. + Applies to ``botsort`` only. + tracker_enable_cmc: Enable camera-motion compensation. Applies to ``botsort`` only. + tracker_cmc_method: Camera-motion compensation method (e.g. ``sparseOptFlow``). Applies to ``botsort`` only. + tracker_cmc_downscale: Frame downscale factor used by CMC. Applies to ``botsort`` only. + tracker_instant_first_frame_activation: Activate tracks immediately on the first frame. Applies to + ``botsort`` only. + tracker_direction_consistency_weight: Weight of the direction-consistency term during association. + Applies to ``ocsort`` only. + tracker_delta_t: Frame gap used for OC-SORT's observation-centric update. Applies to ``ocsort`` only. + out_output: Output video file path. + out_mot_results: Output MOT format file path. + out_overwrite: Overwrite existing output files. + show_display: Show preview window. + show_boxes: Draw bounding boxes. + show_masks: Draw segmentation masks (segmentation models only). + show_labels: Show class labels. + show_ids: Show track IDs. + show_confidence: Show confidence scores. + show_trajectories: Draw track trajectories. + + Returns: + Exit code: 0 on success, 1 on error. + """ + needs_frames = out_output or show_display + + if source is None and not detections: + print( + "Error: --source is required when not using --detections.", + file=sys.stderr, + ) + return 1 + + if detection_model is not None and detections is not None: + print( + "Error: --detection.model and --detections are mutually exclusive.", + file=sys.stderr, + ) + return 1 + + if needs_frames and source is None: + print( + "Error: --source is required when using --out.output or --show.display.", + file=sys.stderr, + ) + return 1 + + if out_output: + _validate_output_path(_resolve_video_output_path(out_output), overwrite=out_overwrite) + if out_mot_results: + _validate_output_path(out_mot_results, overwrite=out_overwrite) + + if detections: + loaded_model = None + detections_data = load_mot_file(detections) + class_names: list[str] = [] + else: + loaded_model = _init_model( + detection_model or DEFAULT_MODEL, + device=detection_device, + api_key=detection_api_key, + ) + detections_data = None + class_names = getattr(loaded_model, "class_names", []) + + class_filter = _resolve_class_filter(filters_classes, class_names) + track_id_filter = _resolve_track_id_filter(filters_track_ids) + + tracker_kwargs: dict[str, object] = { + k: v + for k, v in { + "lost_track_buffer": tracker_lost_track_buffer, + "frame_rate": tracker_frame_rate, + "track_activation_threshold": tracker_track_activation_threshold, + "minimum_consecutive_frames": tracker_minimum_consecutive_frames, + "minimum_iou_threshold": tracker_minimum_iou_threshold, + "high_conf_det_threshold": tracker_high_conf_det_threshold, + "minimum_iou_threshold_first_assoc": tracker_minimum_iou_threshold_first_assoc, + "minimum_iou_threshold_second_assoc": tracker_minimum_iou_threshold_second_assoc, + "minimum_iou_threshold_unconfirmed_assoc": tracker_minimum_iou_threshold_unconfirmed_assoc, + "enable_cmc": tracker_enable_cmc, + "cmc_method": tracker_cmc_method, + "cmc_downscale": tracker_cmc_downscale, + "instant_first_frame_activation": tracker_instant_first_frame_activation, + "direction_consistency_weight": tracker_direction_consistency_weight, + "delta_t": tracker_delta_t, + }.items() + if v is not None + } + tracker_obj = _init_tracker(tracker, **tracker_kwargs) + + if source is not None: + return _run_with_source( + source=source, + loaded_model=loaded_model, + detections_data=detections_data, + class_names=class_names, + class_filter=class_filter, + track_id_filter=track_id_filter, + tracker=tracker_obj, + out_output=out_output, + out_mot_results=out_mot_results, + show_display=show_display, + detection_confidence=detection_confidence, + show_boxes=show_boxes, + show_masks=show_masks, + show_labels=show_labels, + show_ids=show_ids, + show_confidence=show_confidence, + show_trajectories=show_trajectories, + ) + else: + return _run_frameless( + detections_data=detections_data, + class_filter=class_filter, + track_id_filter=track_id_filter, + tracker=tracker_obj, + out_mot_results=out_mot_results, + ) + + +def _run_frameless( + detections_data: dict | None, + class_filter: list[int] | None, + track_id_filter: list[int] | None, + tracker: BaseTracker, + out_mot_results: Path | None, +) -> int: + """Run tracking from pre-computed detections without a frame source.""" + if detections_data is None or not detections_data: + print("Error: No detections found in file.", file=sys.stderr) + return 1 + + total_frames = max(detections_data.keys()) + source_info = _SourceInfo(source_type="video", total_frames=total_frames) + + try: + with ( + _MOTOutput(out_mot_results) as mot, + _TrackingProgress(source_info) as progress, + ): + interrupted = False + for frame_idx in range(1, total_frames + 1): + if frame_idx in detections_data: + dets = _mot_frame_to_detections(detections_data[frame_idx]) + else: + dets = sv.Detections.empty() + + if class_filter is not None and len(dets) > 0 and dets.class_id is not None: + mask = np.isin(dets.class_id, class_filter) + dets = dets[mask] # type: ignore[assignment] + + tracked = tracker.update(dets) + + if track_id_filter is not None and len(tracked) > 0: + if tracked.tracker_id is not None: + mask = np.isin(tracked.tracker_id.astype(int), track_id_filter) + tracked = tracked[mask] # type: ignore[assignment] + + mot.write(frame_idx, tracked) + progress.update() + + progress.complete(interrupted=interrupted) + + except KeyboardInterrupt: + pass + + return 0 + + +def _run_with_source( + source: str, + loaded_model: AnyModel | None, + detections_data: dict | None, + class_names: list[str], + class_filter: list[int] | None, + track_id_filter: list[int] | None, + tracker: BaseTracker, + out_output: Path | None, + out_mot_results: Path | None, + show_display: bool, + detection_confidence: float, + show_boxes: bool, + show_masks: bool, + show_labels: bool, + show_ids: bool, + show_confidence: bool, + show_trajectories: bool, +) -> int: + """Run tracking with a frame source (video, webcam, images).""" + frame_gen = frames_from_source(source) + source_info = _classify_source(source) + + annotators, label_annotator = _init_annotators( + show_boxes=show_boxes, + show_masks=show_masks, + show_labels=show_labels, + show_ids=show_ids, + show_confidence=show_confidence, + ) + trace_annotator = None + if show_trajectories: + trace_annotator = sv.TraceAnnotator( + color=COLOR_PALETTE, + color_lookup=sv.ColorLookup.TRACK, + ) + + display_ctx = _DisplayWindow() if show_display else nullcontext() + + try: + with ( + _VideoOutput( + out_output, + fps=source_info.fps or _DEFAULT_OUTPUT_FPS, + ) as video, + _MOTOutput(out_mot_results) as mot, + display_ctx as display_win, + _TrackingProgress(source_info) as progress, + ): + interrupted = False + for frame_idx, frame in frame_gen: + if loaded_model is not None: + dets = _run_model(loaded_model, frame, detection_confidence) + elif detections_data is not None and frame_idx in detections_data: + dets = _mot_frame_to_detections(detections_data[frame_idx]) + else: + dets = sv.Detections.empty() + + if class_filter is not None and len(dets) > 0 and dets.class_id is not None: + mask = np.isin(dets.class_id, class_filter) + dets = dets[mask] # type: ignore[assignment] + + tracked = tracker.update(dets, frame) + + if track_id_filter is not None and len(tracked) > 0: + if tracked.tracker_id is not None: + mask = np.isin(tracked.tracker_id.astype(int), track_id_filter) + tracked = tracked[mask] # type: ignore[assignment] + + mot.write(frame_idx, tracked) + progress.update() + + if show_display or out_output: + annotated = frame.copy() + if trace_annotator is not None: + annotated = trace_annotator.annotate(annotated, tracked) + for annotator in annotators: + annotated = annotator.annotate(annotated, tracked) + if label_annotator is not None: + labeled: sv.Detections = tracked[tracked.tracker_id != -1] # type: ignore[assignment] + labels = _format_labels( + labeled, + class_names, + show_ids=show_ids, + show_labels=show_labels, + show_confidence=show_confidence, + ) + annotated = label_annotator.annotate(annotated, labeled, labels) # type: ignore[assignment,arg-type] + + video.write(annotated) + + if display_win is not None: + display_win.show(annotated) + if display_win.quit_requested: + interrupted = True + break + + progress.complete(interrupted=interrupted) + + except KeyboardInterrupt: + pass + + return 0 + + +def _resolve_track_id_filter(track_ids_arg: str | None) -> list[int] | None: + """Resolve a comma-separated ``--filters.track-ids`` value to a list of integer IDs. + + Args: + track_ids_arg: Raw ``--filters.track-ids`` string (e.g. ``"1,3,5"``). ``None`` + means no filter. + + Returns: + List of integer track IDs, or ``None`` when no valid filter remains. + + Examples: + >>> _resolve_track_id_filter(None) is None + True + >>> _resolve_track_id_filter("1,3") == [1, 3] + True + """ + if not track_ids_arg: + return None + + track_ids: list[int] = [] + for token in track_ids_arg.split(","): + token = token.strip() + try: + track_ids.append(int(token)) + except ValueError: + print( + f"Warning: '{token}' is not a valid track ID, skipping.", + file=sys.stderr, + ) + return track_ids if track_ids else None + + +def _resolve_class_filter( + classes_arg: str | None, + class_names: list[str], +) -> list[int] | None: + """Resolve a comma-separated ``--filters.classes`` value to a list of integer class IDs. + + Each token is checked independently: if it parses as an ``int`` it is used + directly as a class ID; otherwise it is looked up by name in *class_names*. + Unknown names are printed as warnings and skipped. + + Args: + classes_arg: Raw ``--filters.classes`` string (e.g. ``"person,car"`` or + ``"0,2"`` or ``"person,2"``). ``None`` means no filter. + class_names: Ordered list of class names where the index equals the + class ID (as provided by the model). + + Returns: + List of integer class IDs, or ``None`` when no valid filter remains. + + Examples: + >>> _resolve_class_filter(None, []) is None + True + >>> _resolve_class_filter("0,2", ["person", "bicycle", "car"]) == [0, 2] + True + """ + if not classes_arg: + return None + + requested = [token.strip() for token in classes_arg.split(",")] + name_to_id = {name: i for i, name in enumerate(class_names)} + class_filter: list[int] = [] + for token in requested: + try: + class_filter.append(int(token)) + except ValueError: + if token in name_to_id: + class_filter.append(name_to_id[token]) + else: + print( + f"Warning: class '{token}' not found in model class list, skipping.", + file=sys.stderr, + ) + return class_filter if class_filter else None + + +def _init_model( + model_id: str, + *, + device: str = DEFAULT_DEVICE, + api_key: str | None = None, +) -> AnyModel: + """Load a detection model via inference-models. + + Args: + model_id: Model identifier (e.g. ``'rfdetr-nano'`` or + ``'workspace/project/version'``). + device: Device to load model on (``'auto'``, ``'cpu'``, ``'cuda'``, ``'mps'``). + api_key: Roboflow API key for custom models. + + Returns: + Loaded model instance. + """ + try: + from inference_models import AutoModel + except ImportError as e: + print( + "Error: inference-models is required for model-based detection.\n" + "Install with: pip install 'trackers[detection]'", + file=sys.stderr, + ) + raise SystemExit(1) from e + + resolved_device = _best_device() if device == DEFAULT_DEVICE else device + + return AutoModel.from_pretrained( + model_id, + api_key=api_key, + device=resolved_device, + ) + + +def _run_model(model: AnyModel, frame: np.ndarray, confidence: float) -> sv.Detections: + """Run model inference and return sv.Detections.""" + predictions = model(frame) + if not predictions: + return sv.Detections.empty() + + detections = predictions[0].to_supervision() + + if len(detections) > 0 and detections.confidence is not None: + mask = detections.confidence >= confidence + detections = detections[mask] + + return detections + + +def _init_tracker(tracker_id: str, **kwargs: object) -> BaseTracker: + """Create a tracker instance from the registry. + + Args: + tracker_id: Registered tracker name (e.g. ``'bytetrack'``, ``'sort'``). + **kwargs: Tracker-specific parameters. + + Returns: + Initialized tracker instance. + + Raises: + ValueError: If *tracker_id* is not registered. + """ + info = BaseTracker._lookup_tracker(tracker_id) + if info is None: + available = ", ".join(BaseTracker._registered_trackers()) + raise ValueError(f"Unknown tracker: '{tracker_id}'. Available: {available}") + + return info.tracker_class(**kwargs) + + +def _init_annotators( + show_boxes: bool = False, + show_masks: bool = False, + show_labels: bool = False, + show_ids: bool = False, + show_confidence: bool = False, +) -> tuple[list, sv.LabelAnnotator | None]: + """Initialize supervision annotators based on display options. + + Args: + show_boxes: Create BoxAnnotator. + show_masks: Create MaskAnnotator. + show_labels: Include class labels (triggers LabelAnnotator). + show_ids: Include track IDs (triggers LabelAnnotator). + show_confidence: Include confidence scores (triggers LabelAnnotator). + + Returns: + Tuple of (annotators list, label_annotator or None). + + Examples: + >>> annotators, label_annotator = _init_annotators(show_boxes=True) + >>> len(annotators) + 1 + >>> label_annotator is None + True + """ + annotators: list = [] + label_annotator: sv.LabelAnnotator | None = None + + if show_boxes: + annotators.append( + sv.BoxAnnotator( + color=COLOR_PALETTE, + color_lookup=sv.ColorLookup.TRACK, + ) + ) + + if show_masks: + annotators.append( + sv.MaskAnnotator( + color=COLOR_PALETTE, + color_lookup=sv.ColorLookup.TRACK, + ) + ) + + if show_labels or show_ids or show_confidence: + label_annotator = sv.LabelAnnotator( + color=COLOR_PALETTE, + text_color=sv.Color.BLACK, + text_position=sv.Position.TOP_LEFT, + color_lookup=sv.ColorLookup.TRACK, + ) + + return annotators, label_annotator + + +def _format_labels( + detections: sv.Detections, + class_names: list[str], + *, + show_ids: bool = False, + show_labels: bool = False, + show_confidence: bool = False, +) -> list[str]: + """Generate label strings for each detection. + + Args: + detections: Detections to generate labels for. + class_names: List of class names for lookup. + show_ids: Include tracker IDs in labels. + show_labels: Include class names in labels. + show_confidence: Include confidence scores in labels. + + Returns: + List of label strings, one per detection. + + Examples: + >>> import supervision as sv + >>> import numpy as np + >>> dets = sv.Detections(xyxy=np.array([[0, 0, 1, 1]])) + >>> _format_labels(dets, []) + [''] + """ + labels = [] + + for i in range(len(detections)): + parts = [] + + if show_ids and detections.tracker_id is not None: + parts.append(f"#{int(detections.tracker_id[i])}") + + if show_labels and detections.class_id is not None: + class_id = int(detections.class_id[i]) + if class_names and 0 <= class_id < len(class_names): + parts.append(class_names[class_id]) + else: + parts.append(str(class_id)) + + if show_confidence and detections.confidence is not None: + parts.append(f"{detections.confidence[i]:.2f}") + + labels.append(" ".join(parts)) + + return labels diff --git a/src/trackers/cli/tune.py b/src/trackers/cli/tune.py new file mode 100644 index 00000000..814eb65d --- /dev/null +++ b/src/trackers/cli/tune.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# ------------------------------------------------------------------------ +# 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 json +import sys +from pathlib import Path + + +def tune( + tracker: str, + gt_dir: Path, + detections_dir: Path, + objective: str = "HOTA", + n_trials: int = 100, + metrics: list[str] | None = None, + threshold: float = 0.5, + seqmap: Path | None = None, + fixed_params: dict | None = None, + images_dir: Path | None = None, + enqueue_defaults: bool = True, + seed: int | None = None, + output: Path | None = None, +) -> int: + """Tune tracker hyperparameters using Optuna. + + Args: + tracker: Tracker ID to tune (e.g. bytetrack, sort, ocsort). + gt_dir: Directory of ground-truth MOT files. + detections_dir: Directory of pre-computed detection files in MOT flat + format (one {seq}.txt per sequence). + objective: Scalar metric to maximise (MOTA, HOTA, IDF1). + n_trials: Number of Optuna trials to run. + metrics: Metric families to compute (CLEAR, HOTA, Identity). Default: CLEAR. + threshold: IoU threshold for CLEAR and Identity matching. + seqmap: Sequence map file listing sequences to evaluate. + fixed_params: Tracker kwargs held constant for every trial. + images_dir: MOT image root for frame-based features (e.g. CMC). + enqueue_defaults: Whether to run a baseline trial before sampling. + seed: Random seed for Optuna's TPE sampler. + output: Output file path for best parameters (JSON format). + + Returns: + Exit code: 0 on success, 1 on error. + """ + if metrics is None: + metrics = ["CLEAR"] + + from trackers.tune import Tuner + + try: + tuner = Tuner( + tracker_id=tracker, + gt_dir=gt_dir, + detections_dir=detections_dir, + metrics=metrics, + objective=objective, + n_trials=n_trials, + threshold=threshold, + seqmap=seqmap, + fixed_params=fixed_params, + images_dir=images_dir, + enqueue_defaults=enqueue_defaults, + seed=seed, + ) + except (ValueError, ImportError, FileNotFoundError) as e: + print(str(e), file=sys.stderr) + return 1 + + try: + best_params = tuner.run() + except Exception as e: + print(f"Error during tuning: {e}", file=sys.stderr) + return 1 + + print(f"\nBest parameters for {tracker}:") + for name, value in best_params.items(): + print(f" {name}: {value}") + if tuner.study is not None: + print(f"\nBest {objective}: {tuner.study.best_value:.4f}") + + if output: + try: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(best_params, indent=2)) + except OSError as e: + print(f"Error writing output: {e}", file=sys.stderr) + return 1 + print(f"\nResults saved to: {output}") + + return 0 diff --git a/src/trackers/scripts/__main__.py b/src/trackers/scripts/__main__.py index 0993f8c7..4578ab9b 100644 --- a/src/trackers/scripts/__main__.py +++ b/src/trackers/scripts/__main__.py @@ -5,66 +5,5 @@ # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ -from __future__ import annotations - -import argparse -import sys -import warnings - - -def main() -> int: - """Main entry point for the trackers CLI.""" - # Beta warning - warnings.warn( - "The trackers CLI is in beta. APIs may change in future releases.", - UserWarning, - stacklevel=2, - ) - - parser = argparse.ArgumentParser( - prog="trackers", - description="Command-line tools for multi-object tracking.", - epilog="For more information, visit: https://github.com/roboflow/trackers", - ) - parser.add_argument( - "--version", - action="store_true", - help="Show version and exit.", - ) - - subparsers = parser.add_subparsers( - dest="command", - title="commands", - description="Available commands:", - ) - - # Import and register subcommands - from trackers.scripts.download import add_download_subparser - from trackers.scripts.eval import add_eval_subparser - from trackers.scripts.track import add_track_subparser - from trackers.scripts.tune import add_tune_subparser - - add_download_subparser(subparsers) - add_eval_subparser(subparsers) - add_track_subparser(subparsers) - add_tune_subparser(subparsers) - - # Parse arguments - args = parser.parse_args() - - if args.version: - from importlib.metadata import version - - print(f"trackers {version('trackers')}") - return 0 - - if args.command is None: - parser.print_help() - return 0 - - # Execute the command - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) +# Backward-compat shim — use trackers.cli instead. +from trackers.cli.__main__ import main # noqa: F401 diff --git a/src/trackers/scripts/download.py b/src/trackers/scripts/download.py index de8e461f..4f644535 100644 --- a/src/trackers/scripts/download.py +++ b/src/trackers/scripts/download.py @@ -5,105 +5,5 @@ # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ -from __future__ import annotations - -import argparse -import sys - -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 - - -def add_download_subparser( - subparsers: argparse._SubParsersAction, -) -> None: - """Add the download subcommand to the argument parser.""" - parser = subparsers.add_parser( - "download", - help="Download benchmark tracking datasets.", - description="Download tracking datasets from the official trackers bucket.", - ) - - parser.add_argument( - "--list", - action="store_true", - help="List available datasets, splits, and asset types.", - ) - parser.add_argument( - "dataset", - nargs="?", - help="Dataset name (e.g. mot17, sportsmot).", - ) - parser.add_argument( - "--split", - help="Comma-separated splits to download (e.g. train,val,test). " - "If omitted, all available splits are downloaded.", - ) - parser.add_argument( - "--asset", - help="Comma-separated assets to download: annotations,frames,detections. " - "If omitted, all available assets are downloaded.", - ) - parser.add_argument( - "-o", - "--output", - default=_DEFAULT_OUTPUT_DIR, - help="Output directory (default: current directory).", - ) - parser.add_argument( - "--cache-dir", - default=_DEFAULT_CACHE_DIR, - help="Cache directory for downloaded ZIPs (default: ~/.cache/trackers).", - ) - - parser.set_defaults(func=_run_download) - - -def _run_download(args: argparse.Namespace) -> int: - """Execute the download subcommand.""" - if args.list: - _print_available() - return 0 - - if not args.dataset: - print("Please specify a dataset name or use --list.", file=sys.stderr) - return 1 - - from trackers.datasets.download import download_dataset - - split_list = [s.strip() for s in args.split.split(",")] if args.split else None - asset_list = [a.strip() for a in args.asset.split(",")] if args.asset else None - - try: - download_dataset( - dataset=args.dataset, - split=split_list, - asset=asset_list, - output=args.output, - cache_dir=args.cache_dir, - ) - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - - return 0 - - -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() +# Backward-compat shim — use trackers.cli.download instead. +from trackers.cli.download import _print_available, download # noqa: F401 diff --git a/src/trackers/scripts/eval.py b/src/trackers/scripts/eval.py index 7bd25f21..26395573 100644 --- a/src/trackers/scripts/eval.py +++ b/src/trackers/scripts/eval.py @@ -5,165 +5,5 @@ # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ -from __future__ import annotations - -import argparse -import logging -import sys -from pathlib import Path - - -def add_eval_subparser(subparsers: argparse._SubParsersAction) -> None: - """Add the eval subcommand to the argument parser.""" - parser = subparsers.add_parser( - "eval", - help="Evaluate tracker predictions against ground truth.", - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - # Single sequence mode - single_group = parser.add_argument_group("single sequence evaluation") - single_group.add_argument( - "--gt", - type=Path, - metavar="PATH", - help="Path to ground truth file (MOT format).", - ) - single_group.add_argument( - "--tracker", - type=Path, - metavar="PATH", - help="Path to tracker predictions file (MOT format).", - ) - - # Benchmark mode - bench_group = parser.add_argument_group("benchmark evaluation") - bench_group.add_argument( - "--gt-dir", - type=Path, - metavar="DIR", - help="Directory containing ground truth files.", - ) - bench_group.add_argument( - "--tracker-dir", - type=Path, - metavar="DIR", - help="Directory containing tracker prediction files.", - ) - bench_group.add_argument( - "--seqmap", - type=Path, - metavar="PATH", - help="Sequence map file listing sequences to evaluate.", - ) - - # Common options - parser.add_argument( - "--metrics", - nargs="+", - default=["CLEAR"], - choices=["CLEAR", "HOTA", "Identity"], - help="Metrics to compute. Default: CLEAR. Options: CLEAR, HOTA, Identity", - ) - parser.add_argument( - "--threshold", - type=float, - default=0.5, - help="IoU threshold for CLEAR and Identity matching. Default: 0.5", - ) - parser.add_argument( - "--columns", - nargs="+", - default=None, - metavar="COL", - help=( - "Metric columns to display. Default: auto-selected based on metrics. " - "CLEAR: MOTA, MOTP, MODA, CLR_Re, CLR_Pr, MTR, PTR, MLR, sMOTA, " - "CLR_TP, CLR_FN, CLR_FP, IDSW, MT, PT, ML, Frag. " - "HOTA: HOTA, DetA, AssA, DetRe, DetPr, AssRe, AssPr, LocA. " - "Identity: IDF1, IDR, IDP, IDTP, IDFN, IDFP" - ), - ) - parser.add_argument( - "--output", - "-o", - type=Path, - metavar="PATH", - help="Output file for results (JSON format).", - ) - - parser.set_defaults(func=run_eval) - - -def run_eval(args: argparse.Namespace) -> int: - """Execute the eval command.""" - # Configure logging to show detection info - logging.basicConfig( - level=logging.INFO, - format="%(message)s", - handlers=[logging.StreamHandler(sys.stderr)], - ) - - # Validate arguments - single_mode = args.gt is not None and args.tracker is not None - benchmark_mode = args.gt_dir is not None and args.tracker_dir is not None - - if not single_mode and not benchmark_mode: - print( - "Error: Must specify either --gt/--tracker or --gt-dir/--tracker-dir", - file=sys.stderr, - ) - return 1 - - if single_mode and benchmark_mode: - print( - "Error: Cannot use both single sequence and benchmark mode", - file=sys.stderr, - ) - return 1 - - # Columns: None means auto-select based on available metrics - columns = args.columns - - # Import evaluation functions - from trackers.eval import evaluate_mot_sequence, evaluate_mot_sequences - - try: - if single_mode: - seq_result = evaluate_mot_sequence( - gt_path=args.gt, - tracker_path=args.tracker, - metrics=args.metrics, - threshold=args.threshold, - ) - print(seq_result.table(columns=columns)) - - # Save results if output specified - if args.output: - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(seq_result.json()) - print(f"\nResults saved to: {args.output}") - else: - bench_result = evaluate_mot_sequences( - gt_dir=args.gt_dir, - tracker_dir=args.tracker_dir, - seqmap=args.seqmap, - metrics=args.metrics, - threshold=args.threshold, - ) - print(bench_result.table(columns=columns)) - - # Save results if output specified - if args.output: - bench_result.save(args.output) - print(f"\nResults saved to: {args.output}") - - except FileNotFoundError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - except ValueError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - - return 0 +# Backward-compat shim — use trackers.cli.eval instead. +from trackers.cli.eval import eval_cmd # noqa: F401 diff --git a/src/trackers/scripts/progress.py b/src/trackers/scripts/progress.py index 67de2608..5cedf1cd 100644 --- a/src/trackers/scripts/progress.py +++ b/src/trackers/scripts/progress.py @@ -5,228 +5,10 @@ # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ -from __future__ import annotations - -import itertools -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Literal - -import cv2 -from rich.console import Console -from rich.live import Live -from rich.text import Text - -from trackers.io.video import IMAGE_EXTENSIONS - -_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" -_STREAM_PREFIXES = ("rtsp://", "http://", "https://") -_ICON_OK = "✓" -_ICON_FAIL = "✗" - - -@dataclass -class _SourceInfo: - """Metadata about a frame source used to drive progress display. - - Attributes: - source_type: Kind of source (`video`, `image_dir`, `webcam`, - `stream`). - total_frames: Total frame count when known, `None` for unbounded - sources such as webcams and network streams. - fps: Source frame-rate when known, `None` otherwise. - """ - - source_type: Literal["video", "image_dir", "webcam", "stream"] - total_frames: int | None = None - fps: float | None = None - - -def _classify_source(source: str | Path | int) -> _SourceInfo: - """Classify a frame source and extract metadata. - - The function inspects *source* without consuming any frames so it can be - called before the main processing loop. - - Args: - source: The same value accepted by `frames_from_source`. - - Returns: - A `_SourceInfo` describing the source. - """ - if isinstance(source, int) or (isinstance(source, str) and source.isdigit()): - return _SourceInfo(source_type="webcam") - - source_str = str(source) - - if any(source_str.lower().startswith(p) for p in _STREAM_PREFIXES): - return _SourceInfo(source_type="stream") - - path = Path(source_str) - if path.is_dir(): - count = sum(1 for p in path.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS) - return _SourceInfo( - source_type="image_dir", - total_frames=count if count > 0 else None, - ) - - cap = cv2.VideoCapture(source_str) - if not cap.isOpened(): - # Cannot open; still classify as video - the real error will come - # from frames_from_source later. - return _SourceInfo(source_type="video") - - try: - total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - fps = cap.get(cv2.CAP_PROP_FPS) - return _SourceInfo( - source_type="video", - total_frames=total if total > 0 else None, - fps=fps if fps and fps > 0 else None, - ) - finally: - cap.release() - - -def _format_time(seconds: float) -> str: - """Format `seconds` as `H:MM:SS` or `M:SS`.""" - if seconds < 0: - return "--" - minutes, seconds_remainder = divmod(int(seconds), 60) - hours, minutes = divmod(minutes, 60) - if hours > 0: - return f"{hours}:{minutes:02d}:{seconds_remainder:02d}" - return f"{minutes}:{seconds_remainder:02d}" - - -class _TrackingProgress: - """Context-manager that renders a single live progress line. - - Args: - source_info: Source metadata returned by `_classify_source`. - console: Optional `Console` instance (useful for testing with a - `StringIO` file). - """ - - def __init__( - self, - source_info: _SourceInfo, - console: Console | None = None, - ) -> None: - self._source_info = source_info - self._console = console or Console() - self._frames_processed = 0 - self._start_time: float = 0.0 - self._spinner = itertools.cycle(_SPINNER_FRAMES) - self._live: Live | None = None - self._interrupted = False - - def update(self) -> None: - """Record one processed frame and refresh the display.""" - self._frames_processed += 1 - icon = next(self._spinner) - if self._live is not None: - self._live.update(self._build_line(icon)) - - def complete(self, *, interrupted: bool = False) -> None: - """Signal that the processing loop has ended. - - Must be called before leaving the `with` block so that `__exit__` - can render the correct final state. - - Args: - interrupted: `True` when the loop was terminated early (e.g. - display-quit). - """ - self._interrupted = interrupted - - def __enter__(self) -> _TrackingProgress: - self._start_time = time.monotonic() - self._live = Live( - self._build_line("⠋"), - console=self._console, - refresh_per_second=12, - transient=True, - ) - self._live.__enter__() - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: object, - ) -> None: - if self._live is not None: - self._live.__exit__(None, None, None) - - icon, suffix = self._resolve_final_state(exc_type) - final = self._build_line(icon, show_eta=False, suffix=suffix) - self._console.print(final) - - @property - def _is_bounded(self) -> bool: - """Whether the source has a known total frame count.""" - return self._source_info.total_frames is not None - - def _resolve_final_state(self, exc_type: type[BaseException] | None) -> tuple[str, str]: - """Return `(icon, suffix)` for the final printed line.""" - is_real_error = exc_type is not None and not issubclass(exc_type, KeyboardInterrupt) - - if is_real_error: - return (_ICON_FAIL, "(source lost)") - - was_stopped_early = exc_type is not None or self._interrupted - - if was_stopped_early and self._is_bounded: - return (_ICON_FAIL, "(interrupted)") - - return (_ICON_OK, "") - - def _build_line( - self, - icon: str, - *, - show_eta: bool = True, - suffix: str = "", - ) -> Text: - """Compose the single-line progress string.""" - elapsed = time.monotonic() - self._start_time - fps = self._frames_processed / elapsed if elapsed > 0 else 0.0 - total = self._source_info.total_frames - - if total is not None: - total_str = str(total) - frames_part = f"{self._frames_processed:>{len(total_str)}} / {total_str}" - else: - frames_part = f"{self._frames_processed} / --" - - if total is not None and total > 0: - percentage = self._frames_processed / total * 100 - percentage_part = f"{percentage:>3.0f}%" - else: - percentage_part = " --" - - fps_part = f"{fps:>.1f} fps" - elapsed_part = f"{_format_time(elapsed)} elapsed" - - parts = [ - f"{icon} Tracking", - f"{frames_part} frames", - percentage_part, - fps_part, - elapsed_part, - ] - - if show_eta: - if total is not None and fps > 0: - remaining = (total - self._frames_processed) / fps - parts.append(f"eta {_format_time(remaining)}") - else: - parts.append("eta --") - - if suffix: - parts.append(suffix) - - return Text(" ".join(parts)) +# Backward-compat shim — use trackers.cli.progress instead. +from trackers.cli.progress import ( # noqa: F401 + _classify_source, + _format_time, + _SourceInfo, + _TrackingProgress, +) diff --git a/src/trackers/scripts/track.py b/src/trackers/scripts/track.py index 539a3a23..23ab5072 100644 --- a/src/trackers/scripts/track.py +++ b/src/trackers/scripts/track.py @@ -5,734 +5,16 @@ # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ -from __future__ import annotations - -import argparse -import sys -from contextlib import nullcontext -from pathlib import Path -from typing import TYPE_CHECKING - -import numpy as np -import supervision as sv - -from trackers import frames_from_source -from trackers.core.base import BaseTracker -from trackers.io.mot import _mot_frame_to_detections, _MOTOutput, load_mot_file -from trackers.io.paths import _resolve_video_output_path, _validate_output_path -from trackers.io.video import _DEFAULT_OUTPUT_FPS, _DisplayWindow, _VideoOutput -from trackers.scripts.progress import _classify_source, _SourceInfo, _TrackingProgress -from trackers.utils.device import _best_device - -if TYPE_CHECKING: - from inference_models import AnyModel - -# Defaults -DEFAULT_MODEL = "rfdetr-nano" -DEFAULT_TRACKER = "bytetrack" -DEFAULT_CONFIDENCE = 0.5 -DEFAULT_DEVICE = "auto" - -# Visualization -COLOR_PALETTE = sv.ColorPalette.from_hex( - [ - "#ffff00", - "#ff9b00", - "#ff8080", - "#ff66b2", - "#ff66ff", - "#b266ff", - "#9999ff", - "#3399ff", - "#66ffff", - "#33ff99", - "#66ff66", - "#99ff00", - ] +# Backward-compat shim — use trackers.cli.track instead. +from trackers.cli.track import ( # noqa: F401 + _format_labels, + _init_annotators, + _init_model, + _init_tracker, + _resolve_class_filter, + _resolve_track_id_filter, + _run_frameless, + _run_model, + _run_with_source, + track, ) - - -def add_track_subparser(subparsers: argparse._SubParsersAction) -> None: - """Add the track subcommand to the argument parser.""" - parser = subparsers.add_parser( - "track", - help="Track objects in video using detection and tracking.", - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - # Source options - source_group = parser.add_argument_group("source") - source_group.add_argument( - "--source", - type=str, - default=None, - metavar="PATH", - help="Video file, webcam index (0), RTSP URL, or image directory.", - ) - - # Detection options (mutually exclusive) - detection_group = parser.add_argument_group("detection") - det_mutex = detection_group.add_mutually_exclusive_group(required=False) - det_mutex.add_argument( - "--model", - type=str, - default=DEFAULT_MODEL, - metavar="ID", - help=( - "Model ID for detection. Pretrained: rfdetr-nano, rfdetr-base, etc. " - f"Custom: workspace/project/version. Default: {DEFAULT_MODEL}" - ), - ) - det_mutex.add_argument( - "--detections", - type=Path, - metavar="PATH", - help="Load pre-computed detections from MOT format file.", - ) - - # Model options - model_group = parser.add_argument_group("model options") - model_group.add_argument( - "--model.confidence", - type=float, - default=DEFAULT_CONFIDENCE, - dest="model_confidence", - metavar="FLOAT", - help=f"Detection confidence threshold. Default: {DEFAULT_CONFIDENCE}", - ) - model_group.add_argument( - "--model.device", - type=str, - default=DEFAULT_DEVICE, - dest="model_device", - metavar="DEVICE", - help=f"Device: auto, cpu, cuda, cuda:0, mps. Default: {DEFAULT_DEVICE}", - ) - model_group.add_argument( - "--model.api_key", - type=str, - default=None, - dest="model_api_key", - metavar="KEY", - help="Roboflow API key for custom models.", - ) - - # Filtering options - filter_group = parser.add_argument_group("filtering") - filter_group.add_argument( - "--classes", - type=str, - default=None, - metavar="NAMES_OR_IDS", - help="Filter by class names or IDs (comma-separated, e.g., person,car).", - ) - filter_group.add_argument( - "--track_ids", - type=str, - default=None, - metavar="IDS", - help="Filter output by track IDs (comma-separated, e.g., 1,3,5)", - ) - - # Tracker options - tracker_group = parser.add_argument_group("tracker options") - available_trackers = BaseTracker._registered_trackers() - tracker_group.add_argument( - "--tracker", - type=str, - default=DEFAULT_TRACKER, - choices=available_trackers if available_trackers else [DEFAULT_TRACKER, "sort"], - metavar="ID", - help=f"Tracking algorithm. Default: {DEFAULT_TRACKER}", - ) - - # Add dynamic tracker parameters - _add_tracker_params(tracker_group) - - # Output options - output_group = parser.add_argument_group("output") - output_group.add_argument( - "-o", - "--output", - type=Path, - default=None, - metavar="PATH", - help="Output video file path.", - ) - output_group.add_argument( - "--mot-output", - type=Path, - default=None, - dest="mot_output", - metavar="PATH", - help="Output MOT format file path.", - ) - output_group.add_argument( - "--overwrite", - action="store_true", - help="Overwrite existing output files.", - ) - - # Visualization options - vis_group = parser.add_argument_group("visualization") - vis_group.add_argument( - "--display", - action="store_true", - help="Show preview window.", - ) - vis_group.add_argument( - "--show-boxes", - action="store_true", - default=True, - dest="show_boxes", - help="Draw bounding boxes. Default: True", - ) - vis_group.add_argument( - "--no-boxes", - action="store_false", - dest="show_boxes", - help="Disable bounding boxes.", - ) - vis_group.add_argument( - "--show-masks", - action="store_true", - dest="show_masks", - help="Draw segmentation masks (seg models only).", - ) - vis_group.add_argument( - "--show-labels", - action="store_true", - dest="show_labels", - help="Show class labels.", - ) - vis_group.add_argument( - "--show-ids", - action="store_true", - default=True, - dest="show_ids", - help="Show track IDs. Default: True", - ) - vis_group.add_argument( - "--no-ids", - action="store_false", - dest="show_ids", - help="Disable track IDs.", - ) - vis_group.add_argument( - "--show-confidence", - action="store_true", - dest="show_confidence", - help="Show confidence scores.", - ) - vis_group.add_argument( - "--show-trajectories", - action="store_true", - dest="show_trajectories", - help="Draw track trajectories.", - ) - - parser.set_defaults(func=run_track) - - -def _add_tracker_params(group: argparse._ArgumentGroup) -> None: - """Add tracker-specific parameters from registry to argument group.""" - for tracker_id in BaseTracker._registered_trackers(): - info = BaseTracker._lookup_tracker(tracker_id) - if info is None: - continue - - for param_name, param_info in info.parameters.items(): - arg_name = f"--tracker.{param_name}" - dest_name = f"tracker_{param_name}" - - kwargs: dict = { - "dest": dest_name, - "default": param_info.default_value, - "help": f"{param_info.description} Default: {param_info.default_value}", - } - - if param_info.param_type is bool: - kwargs["action"] = "store_false" if param_info.default_value else "store_true" - else: - kwargs["type"] = param_info.param_type - kwargs["metavar"] = param_info.param_type.__name__.upper() - - try: - group.add_argument(arg_name, **kwargs) - except argparse.ArgumentError: - # Parameter already added by another tracker - pass - - -def run_track(args: argparse.Namespace) -> int: - """Execute the track command.""" - needs_frames = args.output or args.display - - if args.source is None and not args.detections: - print( - "Error: --source is required when not using --detections.", - file=sys.stderr, - ) - return 1 - - if needs_frames and args.source is None: - print( - "Error: --source is required when using --output or --display.", - file=sys.stderr, - ) - return 1 - - # Validate output paths - if args.output: - _validate_output_path(_resolve_video_output_path(args.output), overwrite=args.overwrite) - if args.mot_output: - _validate_output_path(args.mot_output, overwrite=args.overwrite) - - # Create detection source - if args.detections: - model = None - detections_data = load_mot_file(args.detections) - class_names: list[str] = [] - else: - model = _init_model( - args.model, - device=args.model_device, - api_key=args.model_api_key, - ) - detections_data = None - class_names = getattr(model, "class_names", []) - - # Resolve class filter (names and/or integer IDs) - class_filter = _resolve_class_filter(args.classes, class_names) - - track_id_filter = _resolve_track_id_filter(args.track_ids) - - # Create tracker - tracker_params = _extract_tracker_params(args.tracker, args) - tracker = _init_tracker(args.tracker, **tracker_params) - - if args.source is not None: - return _run_with_source( - args, - model, - detections_data, - class_names, - class_filter, - track_id_filter, - tracker, - ) - else: - return _run_frameless( - args, - detections_data, - class_filter, - track_id_filter, - tracker, - ) - - -def _run_frameless( - args: argparse.Namespace, - detections_data: dict | None, - class_filter: list[int] | None, - track_id_filter: list[int] | None, - tracker: BaseTracker, -) -> int: - """Run tracking from pre-computed detections without frame source.""" - if detections_data is None or not detections_data: - print("Error: No detections found in file.", file=sys.stderr) - return 1 - - total_frames = max(detections_data.keys()) - source_info = _SourceInfo(source_type="video", total_frames=total_frames) - - try: - with ( - _MOTOutput(args.mot_output) as mot, - _TrackingProgress(source_info) as progress, - ): - interrupted = False - for frame_idx in range(1, total_frames + 1): - if frame_idx in detections_data: - detections = _mot_frame_to_detections(detections_data[frame_idx]) - else: - detections = sv.Detections.empty() - - if class_filter is not None and len(detections) > 0: - mask = np.isin(detections.class_id, class_filter) - detections = detections[mask] # type: ignore[assignment] - - tracked = tracker.update(detections) - - if track_id_filter is not None and len(tracked) > 0: - if tracked.tracker_id is not None: - mask = np.isin(tracked.tracker_id.astype(int), track_id_filter) - tracked = tracked[mask] - - mot.write(frame_idx, tracked) - progress.update() - - progress.complete(interrupted=interrupted) - - except KeyboardInterrupt: - pass - - return 0 - - -def _run_with_source( - args: argparse.Namespace, - model, - detections_data: dict | None, - class_names: list[str], - class_filter: list[int] | None, - track_id_filter: list[int] | None, - tracker: BaseTracker, -) -> int: - """Run tracking with a frame source (video, webcam, images).""" - frame_gen = frames_from_source(args.source) - source_info = _classify_source(args.source) - - # Setup annotators - annotators, label_annotator = _init_annotators( - show_boxes=args.show_boxes, - show_masks=args.show_masks, - show_labels=args.show_labels, - show_ids=args.show_ids, - show_confidence=args.show_confidence, - ) - trace_annotator = None - if args.show_trajectories: - trace_annotator = sv.TraceAnnotator( - color=COLOR_PALETTE, - color_lookup=sv.ColorLookup.TRACK, - ) - - display_ctx = _DisplayWindow() if args.display else nullcontext() - - try: - with ( - _VideoOutput( - args.output, - fps=source_info.fps or _DEFAULT_OUTPUT_FPS, - ) as video, - _MOTOutput(args.mot_output) as mot, - display_ctx as display, - _TrackingProgress(source_info) as progress, - ): - interrupted = False - for frame_idx, frame in frame_gen: - # Get detections - if model is not None: - detections = _run_model(model, frame, args.model_confidence) - elif detections_data is not None and frame_idx in detections_data: - detections = _mot_frame_to_detections(detections_data[frame_idx]) - else: - detections = sv.Detections.empty() - - # Filter by class - if class_filter is not None and len(detections) > 0: - mask = np.isin(detections.class_id, class_filter) - detections = detections[mask] # type: ignore[assignment] - - # Run tracker - tracked = tracker.update(detections, frame) - - # Filter by track ID - if track_id_filter is not None and len(tracked) > 0: - if tracked.tracker_id is not None: - mask = np.isin(tracked.tracker_id.astype(int), track_id_filter) - tracked = tracked[mask] - - # Write MOT output - mot.write(frame_idx, tracked) - - progress.update() - - # Annotate and display/save frame - if args.display or args.output: - annotated = frame.copy() - if trace_annotator is not None: - annotated = trace_annotator.annotate(annotated, tracked) - for annotator in annotators: - annotated = annotator.annotate(annotated, tracked) - if label_annotator is not None: - labeled = tracked[tracked.tracker_id != -1] - labels = _format_labels( - labeled, - class_names, - show_ids=args.show_ids, - show_labels=args.show_labels, - show_confidence=args.show_confidence, - ) - annotated = label_annotator.annotate(annotated, labeled, labels) - - video.write(annotated) - - if display is not None: - display.show(annotated) - if display.quit_requested: - interrupted = True - break - - progress.complete(interrupted=interrupted) - - except KeyboardInterrupt: - pass - - return 0 - - -def _resolve_track_id_filter(track_ids_arg: str | None) -> list[int] | None: - """Resolve a comma-separated `--track-ids` value to a list of integer IDs. - - Args: - track_ids_arg: Raw `--track-ids` string (e.g. `"1,3,5"`). `None` - means no filter. - - Returns: - List of integer track IDs, or `None` when no valid filter remains. - """ - if not track_ids_arg: - return None - - track_ids: list[int] = [] - for token in track_ids_arg.split(","): - token = token.strip() - try: - track_ids.append(int(token)) - except ValueError: - print( - f"Warning: '{token}' is not a valid track ID, skipping.", - file=sys.stderr, - ) - return track_ids if track_ids else None - - -def _resolve_class_filter( - classes_arg: str | None, - class_names: list[str], -) -> list[int] | None: - """Resolve a comma-separated `--classes` value to a list of integer IDs. - - Each token is checked independently: if it parses as an `int` it is used - directly as a class ID; otherwise it is looked up by name in *class_names*. - Unknown names are printed as warnings and skipped. - - Args: - classes_arg: Raw `--classes` string (e.g. `"person,car"` or - `"0,2"` or `"person,2"`). `None` means no filter. - class_names: Ordered list of class names where the index equals the - class ID (as provided by the model). - - Returns: - List of integer class IDs, or `None` when no valid filter remains. - """ - if not classes_arg: - return None - - requested = [token.strip() for token in classes_arg.split(",")] - name_to_id = {name: i for i, name in enumerate(class_names)} - class_filter: list[int] = [] - for token in requested: - try: - class_filter.append(int(token)) - except ValueError: - if token in name_to_id: - class_filter.append(name_to_id[token]) - else: - print( - f"Warning: class '{token}' not found in model class list, skipping.", - file=sys.stderr, - ) - return class_filter if class_filter else None - - -def _init_model( - model_id: str, - *, - device: str = DEFAULT_DEVICE, - api_key: str | None = None, -) -> AnyModel: - """Load detection model via inference-models. - - Args: - model_id: Model identifier (e.g., 'rfdetr-nano' or 'workspace/project/version'). - device: Device to load model on ('auto', 'cpu', 'cuda', 'mps'). - api_key: Roboflow API key for custom models. - - Returns: - Loaded model instance. - """ - try: - from inference_models import AutoModel - except ImportError as e: - print( - "Error: inference-models is required for model-based detection.\n" - "Install with: pip install 'trackers[detection]'", - file=sys.stderr, - ) - raise SystemExit(1) from e - - resolved_device = _best_device() if device == DEFAULT_DEVICE else device - - return AutoModel.from_pretrained( - model_id, - api_key=api_key, - device=resolved_device, - ) - - -def _run_model(model: AnyModel, frame: np.ndarray, confidence: float) -> sv.Detections: - """Run model inference and return sv.Detections.""" - predictions = model(frame) - if not predictions: - return sv.Detections.empty() - - detections = predictions[0].to_supervision() - - # Filter by confidence - if len(detections) > 0 and detections.confidence is not None: - mask = detections.confidence >= confidence - detections = detections[mask] - - return detections - - -def _extract_tracker_params(tracker_id: str, args: argparse.Namespace) -> dict[str, object]: - """Extract tracker parameters from CLI args. - - Args: - tracker_id: Registered tracker name. - args: Parsed CLI arguments. - - Returns: - Dictionary of tracker parameters with non-None values. - """ - info = BaseTracker._lookup_tracker(tracker_id) - if info is None: - return {} - - params = {} - for param_name in info.parameters: - dest_name = f"tracker_{param_name}" - if hasattr(args, dest_name): - value = getattr(args, dest_name) - if value is not None: - params[param_name] = value - return params - - -def _init_tracker(tracker_id: str, **kwargs: object) -> BaseTracker: - """Create tracker instance from registry. - - Args: - tracker_id: Registered tracker name (e.g., 'bytetrack', 'sort'). - **kwargs: Tracker-specific parameters. - - Returns: - Initialized tracker instance. - - Raises: - ValueError: If tracker_id is not registered. - """ - info = BaseTracker._lookup_tracker(tracker_id) - if info is None: - available = ", ".join(BaseTracker._registered_trackers()) - raise ValueError(f"Unknown tracker: '{tracker_id}'. Available: {available}") - - return info.tracker_class(**kwargs) - - -def _init_annotators( - show_boxes: bool = False, - show_masks: bool = False, - show_labels: bool = False, - show_ids: bool = False, - show_confidence: bool = False, -) -> tuple[list, sv.LabelAnnotator | None]: - """Initialize supervision annotators based on display options. - - Args: - show_boxes: Create BoxAnnotator. - show_masks: Create MaskAnnotator. - show_labels: Include class labels (triggers LabelAnnotator). - show_ids: Include track IDs (triggers LabelAnnotator). - show_confidence: Include confidence scores (triggers LabelAnnotator). - - Returns: - Tuple of (annotators list, label_annotator or None). - Label annotator is separate because it needs custom labels per frame. - """ - annotators: list = [] - label_annotator: sv.LabelAnnotator | None = None - - if show_boxes: - annotators.append( - sv.BoxAnnotator( - color=COLOR_PALETTE, - color_lookup=sv.ColorLookup.TRACK, - ) - ) - - if show_masks: - annotators.append( - sv.MaskAnnotator( - color=COLOR_PALETTE, - color_lookup=sv.ColorLookup.TRACK, - ) - ) - - if show_labels or show_ids or show_confidence: - label_annotator = sv.LabelAnnotator( - color=COLOR_PALETTE, - text_color=sv.Color.BLACK, - text_position=sv.Position.TOP_LEFT, - color_lookup=sv.ColorLookup.TRACK, - ) - - return annotators, label_annotator - - -def _format_labels( - detections: sv.Detections, - class_names: list[str], - *, - show_ids: bool = False, - show_labels: bool = False, - show_confidence: bool = False, -) -> list[str]: - """Generate label strings for each detection. - - Args: - detections: Detections to generate labels for. - class_names: List of class names for lookup. - show_ids: Include tracker IDs in labels. - show_labels: Include class names in labels. - show_confidence: Include confidence scores in labels. - - Returns: - List of label strings, one per detection. - """ - labels = [] - - for i in range(len(detections)): - parts = [] - - if show_ids and detections.tracker_id is not None: - parts.append(f"#{int(detections.tracker_id[i])}") - - if show_labels and detections.class_id is not None: - class_id = int(detections.class_id[i]) - if class_names and 0 <= class_id < len(class_names): - parts.append(class_names[class_id]) - else: - parts.append(str(class_id)) - - if show_confidence and detections.confidence is not None: - parts.append(f"{detections.confidence[i]:.2f}") - - labels.append(" ".join(parts)) - - return labels diff --git a/src/trackers/scripts/tune.py b/src/trackers/scripts/tune.py index 03457432..ef266d5c 100644 --- a/src/trackers/scripts/tune.py +++ b/src/trackers/scripts/tune.py @@ -5,226 +5,5 @@ # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path - - -def add_tune_subparser(subparsers: argparse._SubParsersAction) -> None: - """Add the tune subcommand to the argument parser.""" - parser = subparsers.add_parser( - "tune", - help="Tune tracker hyperparameters via Optuna.", - description=( - "Run Optuna-based hyperparameter optimisation for a registered " - "tracker using pre-computed detections and ground-truth MOT files." - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - parser.add_argument( - "--tracker", - required=True, - metavar="ID", - help="Tracker ID to tune (e.g. bytetrack, sort, ocsort).", - ) - parser.add_argument( - "--gt-dir", - type=Path, - required=True, - metavar="DIR", - help="Directory containing ground-truth MOT files.", - ) - parser.add_argument( - "--detections-dir", - type=Path, - required=True, - metavar="DIR", - help=("Directory containing pre-computed detection files in MOT flat format (one {seq}.txt per sequence)."), - ) - parser.add_argument( - "--objective", - default="HOTA", - choices=["MOTA", "HOTA", "IDF1"], - help="Scalar metric to maximise. Default: HOTA.", - ) - parser.add_argument( - "--n-trials", - type=int, - default=100, - metavar="N", - help="Number of Optuna trials to run. Default: 100.", - ) - parser.add_argument( - "--metrics", - nargs="+", - default=["CLEAR"], - choices=["CLEAR", "HOTA", "Identity"], - help=( - "Metric families to compute. Default: CLEAR. The family required " - "by --objective is added automatically if missing." - ), - ) - parser.add_argument( - "--threshold", - type=float, - default=0.5, - help="IoU threshold for CLEAR and Identity matching. Default: 0.5.", - ) - parser.add_argument( - "--seqmap", - type=Path, - metavar="PATH", - help="Sequence map file listing sequences to evaluate.", - ) - parser.add_argument( - "--fixed-params", - type=str, - metavar="JSON", - help=("JSON object of tracker kwargs held fixed for every trial (e.g. '{\"enable_cmc\": false}')."), - ) - parser.add_argument( - "--images-dir", - type=Path, - metavar="DIR", - help="MOT image root ({sequence}/img1/) for trackers that need frames (e.g. BoTSORT CMC).", - ) - parser.add_argument( - "--no-enqueue-defaults", - action="store_true", - help="Skip the baseline trial that uses tracker/search_space defaults.", - ) - parser.add_argument( - "--seed", - type=int, - default=None, - metavar="N", - help="Random seed for Optuna sampling (reproducible hyperparameter trials).", - ) - parser.add_argument( - "--output", - "-o", - type=Path, - metavar="PATH", - help="Output file for best parameters (JSON format).", - ) - - parser.set_defaults(func=run_tune) - - -def run_tune(args: argparse.Namespace) -> int: - """Execute the tune command.""" - fixed_params = None - if args.fixed_params is not None: - try: - fixed_params = json.loads(args.fixed_params) - except json.JSONDecodeError as e: - print(f"Invalid --fixed-params JSON: {e}", file=sys.stderr) - return 1 - if not isinstance(fixed_params, dict): - print("--fixed-params must be a JSON object", file=sys.stderr) - return 1 - - return tune( - tracker=args.tracker, - gt_dir=args.gt_dir, - detections_dir=args.detections_dir, - objective=args.objective, - n_trials=args.n_trials, - metrics=args.metrics, - threshold=args.threshold, - seqmap=args.seqmap, - fixed_params=fixed_params, - images_dir=args.images_dir, - enqueue_defaults=not args.no_enqueue_defaults, - seed=args.seed, - output=args.output, - ) - - -def tune( - tracker: str, - gt_dir: Path, - detections_dir: Path, - objective: str = "HOTA", - n_trials: int = 100, - metrics: list[str] | None = None, - threshold: float = 0.5, - seqmap: Path | None = None, - fixed_params: dict | None = None, - images_dir: Path | None = None, - enqueue_defaults: bool = True, - seed: int | None = None, - output: Path | None = None, -) -> int: - """Tune tracker hyperparameters using Optuna. - - Args: - tracker: Tracker ID to tune (e.g. bytetrack, sort). - gt_dir: Directory of ground-truth MOT files. - detections_dir: Directory of pre-computed detection files in MOT flat - format (one {seq}.txt per sequence). - objective: Scalar metric to maximise. Options: MOTA, HOTA, IDF1. - n_trials: Number of Optuna trials to run. - metrics: Metric families to compute. Options: CLEAR, HOTA, Identity. - Default: CLEAR. - threshold: IoU threshold for CLEAR and Identity matching. - seqmap: Sequence map file listing sequences to evaluate. - enqueue_defaults: Whether to run a baseline trial before sampling. - fixed_params: Tracker kwargs held constant for every trial. - images_dir: MOT image root for frame-based features (e.g. CMC). - seed: Random seed for Optuna's TPE sampler. - output: Output file path for best parameters (JSON format). - - Returns: - Exit code: 0 on success, 1 on error. - """ - if metrics is None: - metrics = ["CLEAR"] - - from trackers.tune import Tuner - - try: - tuner = Tuner( - tracker_id=tracker, - gt_dir=gt_dir, - detections_dir=detections_dir, - metrics=metrics, - objective=objective, - n_trials=n_trials, - enqueue_defaults=enqueue_defaults, - fixed_params=fixed_params, - images_dir=images_dir, - seed=seed, - threshold=threshold, - seqmap=seqmap, - ) - except (ValueError, ImportError, FileNotFoundError) as e: - print(str(e), file=sys.stderr) - return 1 - - try: - best_params = tuner.run() - except Exception as e: - print(f"Error during tuning: {e}", file=sys.stderr) - return 1 - - print(f"\nBest parameters for {tracker}:") - for name, value in best_params.items(): - print(f" {name}: {value}") - if tuner.study is not None: - print(f"\nBest {objective}: {tuner.study.best_value:.4f}") - - if output: - try: - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(json.dumps(best_params, indent=2)) - except OSError as e: - print(f"Error writing output: {e}", file=sys.stderr) - return 1 - print(f"\nResults saved to: {output}") - - return 0 +# Backward-compat shim — use trackers.cli.tune instead. +from trackers.cli.tune import tune # noqa: F401 diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 00000000..57226e88 --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1,5 @@ +# ------------------------------------------------------------------------ +# Trackers +# Copyright (c) 2026 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ diff --git a/tests/cli/test_download.py b/tests/cli/test_download.py new file mode 100644 index 00000000..80a6d780 --- /dev/null +++ b/tests/cli/test_download.py @@ -0,0 +1,135 @@ +# ------------------------------------------------------------------------ +# 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 unittest.mock import patch + +import pytest + +from trackers.cli.download import _print_available, download +from trackers.datasets.download import _DEFAULT_CACHE_DIR, _DEFAULT_OUTPUT_DIR + + +class TestDownloadList: + def test_list_triggers_print(self) -> None: + """list_available=True calls _print_available and returns 0.""" + with patch("trackers.cli.download._print_available") as mock_print: + rc = download(list_available=True) + assert rc == 0 + mock_print.assert_called_once() + + def test_list_takes_precedence_over_dataset(self) -> None: + """list_available=True wins over a provided dataset name.""" + with patch("trackers.cli.download._print_available") as mock_print: + rc = download(dataset="mot17", list_available=True) + assert rc == 0 + mock_print.assert_called_once() + + def test_prints_without_error(self, capsys: pytest.CaptureFixture[str]) -> None: + """_print_available runs without raising.""" + _print_available() + capsys.readouterr() + + +class TestDownloadMissingDataset: + def test_missing_dataset_returns_error(self, capsys: pytest.CaptureFixture[str]) -> None: + """No dataset and no list_ prints to stderr and returns 1.""" + rc = download() + captured = capsys.readouterr() + assert rc == 1 + assert "Please specify a dataset" in captured.err + + +class TestDownloadExecution: + @pytest.mark.parametrize( + "split_arg,expected_splits", + [ + ("train", ["train"]), + ("train,val", ["train", "val"]), + ("train,val,test", ["train", "val", "test"]), + ], + ) + def test_split_comma_parsing(self, split_arg: str, expected_splits: list[str]) -> None: + """split values are split on commas and whitespace-stripped.""" + with patch("trackers.datasets.download.download_dataset") as mock_dl: + rc = download(dataset="mot17", split=split_arg, asset="annotations") + assert rc == 0 + mock_dl.assert_called_once_with( + dataset="mot17", + split=expected_splits, + asset=["annotations"], + output=_DEFAULT_OUTPUT_DIR, + cache_dir=_DEFAULT_CACHE_DIR, + ) + + @pytest.mark.parametrize( + "asset_arg,expected_assets", + [ + ("annotations", ["annotations"]), + ("frames,annotations", ["frames", "annotations"]), + ], + ) + def test_asset_comma_parsing(self, asset_arg: str, expected_assets: list[str]) -> None: + """asset values are split on commas.""" + with patch("trackers.datasets.download.download_dataset") as mock_dl: + rc = download(dataset="sportsmot", split="train", asset=asset_arg) + assert rc == 0 + mock_dl.assert_called_once_with( + dataset="sportsmot", + split=["train"], + asset=expected_assets, + output=_DEFAULT_OUTPUT_DIR, + cache_dir=_DEFAULT_CACHE_DIR, + ) + + def test_empty_split_and_asset_pass_none(self) -> None: + """Empty split/asset strings forward None to download_dataset.""" + with patch("trackers.datasets.download.download_dataset") as mock_dl: + rc = download(dataset="mot17") + assert rc == 0 + mock_dl.assert_called_once_with( + dataset="mot17", + split=None, + asset=None, + output=_DEFAULT_OUTPUT_DIR, + cache_dir=_DEFAULT_CACHE_DIR, + ) + + def test_custom_output_forwarded(self) -> None: + """output value is forwarded to download_dataset.""" + with patch("trackers.datasets.download.download_dataset") as mock_dl: + rc = download(dataset="mot17", output="/custom/path") + assert rc == 0 + mock_dl.assert_called_once_with( + dataset="mot17", + split=None, + asset=None, + output="/custom/path", + cache_dir=_DEFAULT_CACHE_DIR, + ) + + def test_exception_returns_error(self) -> None: + """Exception from download_dataset is caught and returns 1.""" + with patch( + "trackers.datasets.download.download_dataset", + side_effect=ValueError("bad dataset"), + ): + rc = download(dataset="mot17") + assert rc == 1 + + def test_split_whitespace_stripped(self) -> None: + """Whitespace around commas in split is stripped.""" + with patch("trackers.datasets.download.download_dataset") as mock_dl: + rc = download(dataset="mot17", split="train , val", asset="annotations") + assert rc == 0 + mock_dl.assert_called_once_with( + dataset="mot17", + split=["train", "val"], + asset=["annotations"], + output=_DEFAULT_OUTPUT_DIR, + cache_dir=_DEFAULT_CACHE_DIR, + ) diff --git a/tests/scripts/test_progress.py b/tests/cli/test_progress.py similarity index 98% rename from tests/scripts/test_progress.py rename to tests/cli/test_progress.py index 91486afe..85d3bab3 100644 --- a/tests/scripts/test_progress.py +++ b/tests/cli/test_progress.py @@ -17,7 +17,7 @@ import pytest from rich.console import Console -from trackers.scripts.progress import ( +from trackers.cli.progress import ( _classify_source, _format_time, _SourceInfo, @@ -129,7 +129,7 @@ def test_video_with_zero_frame_count(self) -> None: cv2.CAP_PROP_FPS: 30.0, }.get(prop, 0.0) - with patch("trackers.scripts.progress.cv2.VideoCapture", return_value=mock_cap): + with patch("trackers.cli.progress.cv2.VideoCapture", return_value=mock_cap): info = _classify_source("some_video.mp4") assert info.source_type == "video" diff --git a/tests/scripts/test_track.py b/tests/cli/test_track.py similarity index 83% rename from tests/scripts/test_track.py rename to tests/cli/test_track.py index be3867ed..32cedca2 100644 --- a/tests/scripts/test_track.py +++ b/tests/cli/test_track.py @@ -12,7 +12,7 @@ import pytest import supervision as sv -from trackers.scripts.track import ( +from trackers.cli.track import ( _format_labels, _init_annotators, _resolve_class_filter, @@ -194,3 +194,34 @@ def test_all_non_integer_returns_none(self, capsys: pytest.CaptureFixture) -> No result = _resolve_track_id_filter("abc,def") assert result is None assert "abc" in capsys.readouterr().err + + +class TestTrackerParamSync: + """Guard that all primitive tracker params are exposed in track().""" + + _CLI_TYPES: ClassVar[tuple[type, ...]] = (int, float, str, bool) + + def test_all_cli_tracker_params_in_track_signature(self) -> None: + """Every primitive-typed tracker registry param must appear as tracker_ in track().""" + import inspect + + from trackers.cli.track import track + from trackers.core.base import BaseTracker + + sig_params = set(inspect.signature(track).parameters) + missing: list[str] = [] + + for tracker_id in BaseTracker._registered_trackers(): + info = BaseTracker._lookup_tracker(tracker_id) + if info is None: + continue + for param_name, param_info in info.parameters.items(): + if param_info.param_type not in self._CLI_TYPES: + continue + expected = f"tracker_{param_name}" + if expected not in sig_params: + missing.append(f"{tracker_id}.{param_name} → {expected}") + + assert not missing, "These tracker params are missing from track() signature:\n" + "\n".join( + f" {m}" for m in missing + ) diff --git a/tests/cli/test_tune.py b/tests/cli/test_tune.py new file mode 100644 index 00000000..ba7b114d --- /dev/null +++ b/tests/cli/test_tune.py @@ -0,0 +1,124 @@ +# ------------------------------------------------------------------------ +# Trackers +# Copyright (c) 2026 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +"""Tests for trackers/cli/tune.py.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from trackers.cli.tune import tune + + +class TestTune: + def test_returns_1_on_invalid_tracker(self, tmp_path: Path) -> None: + """Invalid tracker ID causes tune() to return exit code 1.""" + result = tune("nonexistent_tracker_xyz", tmp_path / "gt", tmp_path / "det") + assert result == 1 + + def test_returns_1_on_missing_files(self, tmp_path: Path) -> None: + """FileNotFoundError from Tuner (empty det_dir) returns exit code 1.""" + gt_dir = tmp_path / "gt" + gt_dir.mkdir() + det_dir = tmp_path / "det" + det_dir.mkdir() + result = tune("bytetrack", gt_dir, det_dir) + assert result == 1 + + def test_returns_1_on_import_error(self, tmp_path: Path) -> None: + """ImportError (e.g. optuna not installed) causes tune() to return 1.""" + with patch( + "trackers.tune.Tuner", + side_effect=ImportError("optuna is required"), + ): + result = tune("bytetrack", tmp_path / "gt", tmp_path / "det") + assert result == 1 + + def test_returns_0_on_success(self, tmp_path: Path) -> None: + """tune() returns 0 when Tuner.run() completes without error.""" + mock_tuner = MagicMock() + mock_tuner.run.return_value = {"high_thresh": 0.6} + mock_tuner.study = None + with patch("trackers.tune.Tuner", return_value=mock_tuner): + result = tune("bytetrack", tmp_path / "gt", tmp_path / "det") + assert result == 0 + + def test_writes_json_output_on_success(self, tmp_path: Path) -> None: + """Best parameters are written to the output JSON file on success.""" + output_path = tmp_path / "out" / "params.json" + best = {"high_thresh": 0.6, "match_thresh": 0.8} + mock_tuner = MagicMock() + mock_tuner.run.return_value = best + mock_tuner.study = None + with patch("trackers.tune.Tuner", return_value=mock_tuner): + result = tune("bytetrack", tmp_path / "gt", tmp_path / "det", output=output_path) + assert result == 0 + assert output_path.exists() + assert json.loads(output_path.read_text()) == best + + def test_returns_1_on_oserror_writing_output(self, tmp_path: Path) -> None: + """OSError while writing output file returns exit code 1.""" + output_path = tmp_path / "params.json" + mock_tuner = MagicMock() + mock_tuner.run.return_value = {"high_thresh": 0.6} + mock_tuner.study = None + with ( + patch("trackers.tune.Tuner", return_value=mock_tuner), + patch.object(Path, "write_text", side_effect=OSError("permission denied")), + ): + result = tune("bytetrack", tmp_path / "gt", tmp_path / "det", output=output_path) + assert result == 1 + + def test_returns_1_on_tuner_run_exception(self, tmp_path: Path) -> None: + """Exception from tuner.run() causes tune() to return exit code 1.""" + mock_tuner = MagicMock() + mock_tuner.run.side_effect = RuntimeError("optimization failed") + with patch("trackers.tune.Tuner", return_value=mock_tuner): + result = tune("bytetrack", tmp_path / "gt", tmp_path / "det") + assert result == 1 + + def test_fixed_params_forwarded_to_tuner(self, tmp_path: Path) -> None: + """fixed_params dict is passed through to Tuner constructor.""" + mock_tuner = MagicMock() + mock_tuner.run.return_value = {} + mock_tuner.study = None + with patch("trackers.tune.Tuner", return_value=mock_tuner) as mock_cls: + tune("bytetrack", tmp_path / "gt", tmp_path / "det", fixed_params={"enable_cmc": False}) + _, kwargs = mock_cls.call_args + assert kwargs["fixed_params"] == {"enable_cmc": False} + + def test_images_dir_forwarded_to_tuner(self, tmp_path: Path) -> None: + """images_dir Path is passed through to Tuner constructor.""" + mock_tuner = MagicMock() + mock_tuner.run.return_value = {} + mock_tuner.study = None + images = tmp_path / "images" + with patch("trackers.tune.Tuner", return_value=mock_tuner) as mock_cls: + tune("bytetrack", tmp_path / "gt", tmp_path / "det", images_dir=images) + _, kwargs = mock_cls.call_args + assert kwargs["images_dir"] == images + + def test_enqueue_defaults_false_forwarded_to_tuner(self, tmp_path: Path) -> None: + """enqueue_defaults=False is passed through to Tuner constructor.""" + mock_tuner = MagicMock() + mock_tuner.run.return_value = {} + mock_tuner.study = None + with patch("trackers.tune.Tuner", return_value=mock_tuner) as mock_cls: + tune("bytetrack", tmp_path / "gt", tmp_path / "det", enqueue_defaults=False) + _, kwargs = mock_cls.call_args + assert kwargs["enqueue_defaults"] is False + + def test_seed_forwarded_to_tuner(self, tmp_path: Path) -> None: + """seed integer is passed through to Tuner constructor.""" + mock_tuner = MagicMock() + mock_tuner.run.return_value = {} + mock_tuner.study = None + with patch("trackers.tune.Tuner", return_value=mock_tuner) as mock_cls: + tune("bytetrack", tmp_path / "gt", tmp_path / "det", seed=42) + _, kwargs = mock_cls.call_args + assert kwargs["seed"] == 42 diff --git a/tests/scripts/test_download.py b/tests/scripts/test_download.py deleted file mode 100644 index 94b3f573..00000000 --- a/tests/scripts/test_download.py +++ /dev/null @@ -1,230 +0,0 @@ -# ------------------------------------------------------------------------ -# 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 argparse -from unittest.mock import patch - -import pytest - -from trackers.datasets.download import _DEFAULT_CACHE_DIR, _DEFAULT_OUTPUT_DIR -from trackers.scripts.download import ( - _print_available, - _run_download, - add_download_subparser, -) - - -def _parse_args(argv: list[str]) -> argparse.Namespace: - """Parse argv through a fresh download subparser and return the namespace.""" - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers() - add_download_subparser(subparsers) - return parser.parse_args(argv) - - -class TestSubparserRegistration: - """Argument parsing and help strings.""" - - def test_list_flag(self) -> None: - """--list sets the flag to True.""" - args = _parse_args(["download", "--list"]) - assert args.list is True - - def test_list_flag_default_false(self) -> None: - """--list is False when omitted.""" - args = _parse_args(["download", "mot17"]) - assert args.list is False - - def test_split_flag_accepts_comma_separated(self) -> None: - """--split accepts comma-separated values.""" - args = _parse_args(["download", "mot17", "--split", "train,val"]) - assert args.split == "train,val" - - def test_asset_flag_accepts_comma_separated(self) -> None: - """--asset accepts comma-separated values.""" - args = _parse_args(["download", "mot17", "--asset", "frames,annotations"]) - assert args.asset == "frames,annotations" - - def test_output_directory_short_flag(self) -> None: - """-o sets the output directory.""" - args = _parse_args(["download", "mot17", "-o", "./datasets"]) - assert args.output == "./datasets" - - def test_cache_dir_flag(self) -> None: - """--cache-dir sets the cache directory.""" - args = _parse_args(["download", "mot17", "--cache-dir", "./cache"]) - assert args.cache_dir == "./cache" - - def test_dataset_positional(self) -> None: - """Dataset is captured as positional argument.""" - args = _parse_args(["download", "sportsmot"]) - assert args.dataset == "sportsmot" - - -class TestRunDownload: - """Execution of the download subcommand.""" - - def test_list_triggers_print(self) -> None: - """--list calls _print_available and returns 0.""" - args = _parse_args(["download", "--list"]) - - with patch("trackers.scripts.download._print_available") as mock_print: - rc = _run_download(args) - assert rc == 0 - mock_print.assert_called_once() - - def test_list_takes_precedence_over_dataset(self) -> None: - """--list wins over dataset positional.""" - args = _parse_args(["download", "mot17", "--list"]) - - with patch("trackers.scripts.download._print_available") as mock_print: - rc = _run_download(args) - assert rc == 0 - mock_print.assert_called_once() - - def test_missing_dataset_exits_with_error(self, capsys: pytest.CaptureFixture[str]) -> None: - """No dataset and no --list prints error to stderr and returns 1.""" - args = _parse_args(["download"]) - rc = _run_download(args) - captured = capsys.readouterr() - assert rc == 1 - assert "Please specify a dataset" in captured.err - - @pytest.mark.parametrize( - "split_arg,expected_splits", - [ - ("train", ["train"]), - ("train,val", ["train", "val"]), - ("train,val,test", ["train", "val", "test"]), - ], - ) - def test_split_comma_parsing(self, split_arg: str, expected_splits: list[str]) -> None: - """--split values are split on commas and whitespace-stripped.""" - args = _parse_args(["download", "mot17", "--split", split_arg, "--asset", "annotations"]) - - with patch("trackers.datasets.download.download_dataset") as mock_dl: - rc = _run_download(args) - assert rc == 0 - mock_dl.assert_called_once_with( - dataset="mot17", - split=expected_splits, - asset=["annotations"], - output=_DEFAULT_OUTPUT_DIR, - cache_dir=_DEFAULT_CACHE_DIR, - ) - - @pytest.mark.parametrize( - "split_arg,expected_splits", - [ - ("train,", ["train", ""]), - (",train", ["", "train"]), - ("train,,val", ["train", "", "val"]), - ], - ) - def test_split_comma_parsing_boundary(self, split_arg: str, expected_splits: list[str]) -> None: - """--split handles malformed comma inputs gracefully.""" - args = _parse_args(["download", "mot17", "--split", split_arg, "--asset", "annotations"]) - - with patch("trackers.datasets.download.download_dataset") as mock_dl: - rc = _run_download(args) - assert rc == 0 - mock_dl.assert_called_once_with( - dataset="mot17", - split=expected_splits, - asset=["annotations"], - output=_DEFAULT_OUTPUT_DIR, - cache_dir=_DEFAULT_CACHE_DIR, - ) - - @pytest.mark.parametrize( - "asset_arg,expected_assets", - [ - ("annotations", ["annotations"]), - ("frames,annotations", ["frames", "annotations"]), - ("frames,annotations,detections", ["frames", "annotations", "detections"]), - ], - ) - def test_asset_comma_parsing(self, asset_arg: str, expected_assets: list[str]) -> None: - """--asset values are split on commas and whitespace-stripped.""" - args = _parse_args(["download", "sportsmot", "--split", "train", "--asset", asset_arg]) - - with patch("trackers.datasets.download.download_dataset") as mock_dl: - rc = _run_download(args) - assert rc == 0 - mock_dl.assert_called_once_with( - dataset="sportsmot", - split=["train"], - asset=expected_assets, - output=_DEFAULT_OUTPUT_DIR, - cache_dir=_DEFAULT_CACHE_DIR, - ) - - def test_none_splits_and_assets_when_omitted(self) -> None: - """When --split and --asset are omitted, None is forwarded.""" - args = _parse_args(["download", "mot17"]) - - with patch("trackers.datasets.download.download_dataset") as mock_dl: - rc = _run_download(args) - assert rc == 0 - mock_dl.assert_called_once_with( - dataset="mot17", - split=None, - asset=None, - output=_DEFAULT_OUTPUT_DIR, - cache_dir=_DEFAULT_CACHE_DIR, - ) - - def test_output_directory_forwarded(self) -> None: - """-o value is forwarded to download_dataset.""" - args = _parse_args(["download", "mot17", "-o", "/custom/path"]) - - with patch("trackers.datasets.download.download_dataset") as mock_dl: - rc = _run_download(args) - assert rc == 0 - mock_dl.assert_called_once_with( - dataset="mot17", - split=None, - asset=None, - output="/custom/path", - cache_dir=_DEFAULT_CACHE_DIR, - ) - - def test_value_error_returns_exit_code(self) -> None: - """ValueError from download_dataset is caught and returns 1.""" - args = _parse_args(["download", "mot17"]) - - with patch( - "trackers.datasets.download.download_dataset", - side_effect=ValueError("bad dataset"), - ): - rc = _run_download(args) - assert rc == 1 - - def test_split_with_spaces_stripped(self) -> None: - """--split with spaces around commas strips whitespace.""" - args = _parse_args(["download", "mot17", "--split", "train , val", "--asset", "annotations"]) - - with patch("trackers.datasets.download.download_dataset") as mock_dl: - rc = _run_download(args) - assert rc == 0 - mock_dl.assert_called_once_with( - dataset="mot17", - split=["train", "val"], - asset=["annotations"], - output=_DEFAULT_OUTPUT_DIR, - cache_dir=_DEFAULT_CACHE_DIR, - ) - - -class TestPrintAvailable: - """Output of --list.""" - - def test_prints_without_error(self, capsys: pytest.CaptureFixture[str]) -> None: - """_print_available runs without raising and does not leak output.""" - _print_available() - capsys.readouterr() diff --git a/tests/scripts/test_tune.py b/tests/scripts/test_tune.py deleted file mode 100644 index 24169e20..00000000 --- a/tests/scripts/test_tune.py +++ /dev/null @@ -1,221 +0,0 @@ -# ------------------------------------------------------------------------ -# Trackers -# Copyright (c) 2026 Roboflow. All Rights Reserved. -# Licensed under the Apache License, Version 2.0 [see LICENSE for details] -# ------------------------------------------------------------------------ - -"""CLI-level tests for trackers/scripts/tune.py.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from trackers.scripts.tune import add_tune_subparser, run_tune, tune - - -def _make_parser() -> tuple[argparse.ArgumentParser, argparse._SubParsersAction]: - """Return a top-level parser with a subparsers group.""" - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers() - return parser, subparsers - - -class TestAddTuneSubparser: - @pytest.fixture - def minimal_args(self) -> argparse.Namespace: - """Parsed args with only required flags.""" - parser, subparsers = _make_parser() - add_tune_subparser(subparsers) - return parser.parse_args(["tune", "--tracker", "sort", "--gt-dir", "/gt", "--detections-dir", "/det"]) - - def test_registers_tune_subcommand(self) -> None: - """tune subcommand is accessible under the 'tune' name.""" - parser, subparsers = _make_parser() - add_tune_subparser(subparsers) - args = parser.parse_args(["tune", "--tracker", "sort", "--gt-dir", "/gt", "--detections-dir", "/det"]) - assert args.func is run_tune - - def test_required_args_parsed(self) -> None: - """--tracker, --gt-dir, and --detections-dir are required and parsed.""" - parser, subparsers = _make_parser() - add_tune_subparser(subparsers) - args = parser.parse_args( - [ - "tune", - "--tracker", - "bytetrack", - "--gt-dir", - "/data/gt", - "--detections-dir", - "/data/det", - ] - ) - assert args.tracker == "bytetrack" - assert args.gt_dir == Path("/data/gt") - assert args.detections_dir == Path("/data/det") - - @pytest.mark.parametrize( - "flag,expected", - [ - ("objective", "HOTA"), - ("n_trials", 100), - ("threshold", 0.5), - ("seqmap", None), - ("output", None), - ], - ) - def test_optional_defaults(self, minimal_args: argparse.Namespace, flag: str, expected: object) -> None: - """Optional arguments have correct defaults when omitted.""" - assert getattr(minimal_args, flag) == expected - - def test_metrics_default(self, minimal_args: argparse.Namespace) -> None: - """--metrics defaults to ['CLEAR'] when not supplied.""" - assert minimal_args.metrics == ["CLEAR"] - - def test_output_flag_short_form(self) -> None: - """-o is an alias for --output.""" - parser, subparsers = _make_parser() - add_tune_subparser(subparsers) - args = parser.parse_args( - [ - "tune", - "--tracker", - "sort", - "--gt-dir", - "/gt", - "--detections-dir", - "/det", - "-o", - "/out/params.json", - ] - ) - assert args.output == Path("/out/params.json") - - -class TestTune: - def test_returns_1_on_invalid_tracker(self, tmp_path: Path) -> None: - """Invalid tracker ID causes tune() to return exit code 1.""" - gt_dir = tmp_path / "gt" - gt_dir.mkdir() - det_dir = tmp_path / "det" - det_dir.mkdir() - result = tune("nonexistent_tracker_xyz", gt_dir, det_dir) - assert result == 1 - - def test_returns_1_on_missing_files(self, tmp_path: Path) -> None: - """FileNotFoundError from Tuner (missing sequence files) returns exit code 1.""" - gt_dir = tmp_path / "gt" - gt_dir.mkdir() - det_dir = tmp_path / "det" - det_dir.mkdir() - # bytetrack is registered; empty det_dir → FileNotFoundError via Tuner - result = tune("bytetrack", gt_dir, det_dir) - assert result == 1 - - def test_returns_1_on_import_error(self, tmp_path: Path) -> None: - """ImportError (e.g. optuna not installed) causes tune() to return 1.""" - gt_dir = tmp_path / "gt" - det_dir = tmp_path / "det" - with patch( - "trackers.tune.Tuner", - side_effect=ImportError("optuna is required"), - ): - result = tune("bytetrack", gt_dir, det_dir) - assert result == 1 - - def test_returns_0_on_success(self, tmp_path: Path) -> None: - """tune() returns 0 when Tuner.run() completes without error.""" - gt_dir = tmp_path / "gt" - det_dir = tmp_path / "det" - mock_tuner = MagicMock() - mock_tuner.run.return_value = {"high_thresh": 0.6} - mock_tuner.study = None - with patch("trackers.tune.Tuner", return_value=mock_tuner): - result = tune("bytetrack", gt_dir, det_dir) - assert result == 0 - - def test_writes_json_output_on_success(self, tmp_path: Path) -> None: - """Best parameters are written to the output JSON file on success.""" - gt_dir = tmp_path / "gt" - det_dir = tmp_path / "det" - output_path = tmp_path / "out" / "params.json" - best = {"high_thresh": 0.6, "match_thresh": 0.8} - mock_tuner = MagicMock() - mock_tuner.run.return_value = best - mock_tuner.study = None - with patch("trackers.tune.Tuner", return_value=mock_tuner): - result = tune("bytetrack", gt_dir, det_dir, output=output_path) - assert result == 0 - assert output_path.exists() - assert json.loads(output_path.read_text()) == best - - def test_returns_1_on_oserror_writing_output(self, tmp_path: Path) -> None: - """OSError while writing output file returns exit code 1.""" - gt_dir = tmp_path / "gt" - det_dir = tmp_path / "det" - output_path = tmp_path / "params.json" - mock_tuner = MagicMock() - mock_tuner.run.return_value = {"high_thresh": 0.6} - mock_tuner.study = None - with ( - patch("trackers.tune.Tuner", return_value=mock_tuner), - patch.object(Path, "write_text", side_effect=OSError("permission denied")), - ): - result = tune("bytetrack", gt_dir, det_dir, output=output_path) - assert result == 1 - - def test_returns_1_on_tuner_run_exception(self, tmp_path: Path) -> None: - """Exception from tuner.run() causes tune() to return exit code 1.""" - gt_dir = tmp_path / "gt" - det_dir = tmp_path / "det" - mock_tuner = MagicMock() - mock_tuner.run.side_effect = RuntimeError("optimization failed") - with patch("trackers.tune.Tuner", return_value=mock_tuner): - result = tune("bytetrack", gt_dir, det_dir) - assert result == 1 - - -class TestRunTune: - def test_delegates_to_tune_with_namespace_args(self, tmp_path: Path) -> None: - """run_tune() passes all argparse.Namespace fields to tune() correctly.""" - gt_dir = tmp_path / "gt" - det_dir = tmp_path / "det" - output_path = tmp_path / "params.json" - args = argparse.Namespace( - tracker="sort", - gt_dir=gt_dir, - detections_dir=det_dir, - objective="MOTA", - n_trials=50, - metrics=["CLEAR", "HOTA"], - threshold=0.3, - seqmap=None, - fixed_params=None, - images_dir=None, - no_enqueue_defaults=False, - seed=None, - output=output_path, - ) - with patch("trackers.scripts.tune.tune", return_value=0) as mock_tune: - result = run_tune(args) - assert result == 0 - mock_tune.assert_called_once_with( - tracker="sort", - gt_dir=gt_dir, - detections_dir=det_dir, - objective="MOTA", - n_trials=50, - metrics=["CLEAR", "HOTA"], - threshold=0.3, - seqmap=None, - fixed_params=None, - images_dir=None, - enqueue_defaults=True, - seed=None, - output=output_path, - ) diff --git a/uv.lock b/uv.lock index 08d017d8..abe36f61 100644 --- a/uv.lock +++ b/uv.lock @@ -2,17 +2,22 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform == 'darwin'", "python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform == 'darwin'", "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.14' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform != 'darwin'", + "(python_full_version >= '3.15' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform != 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", @@ -118,6 +123,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -231,13 +276,18 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.14' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform != 'darwin'", + "(python_full_version >= '3.15' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform != 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", @@ -625,6 +675,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "defopt" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "sphinxcontrib-napoleon" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/53/89e5eaa8e775cef5b66254365f51c389ca167b0fa96713b722824708b720/defopt-7.0.0.tar.gz", hash = "sha256:d7ac98810005880717e1df62527fd35dfe083f551b6d4fb5da0b25f608e8ef4c", size = 44527, upload-time = "2025-06-15T21:42:35.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/be/03e94203daf8653bb402936820ac762064b4ef6079daab47581506c187c7/defopt-7.0.0-py3-none-any.whl", hash = "sha256:88b6747561d803e3fc020b9080fd0aa6a300927f64167c76d95544495f1a8307", size = 17795, upload-time = "2025-06-19T06:48:48.835Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -1354,6 +1418,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, ] +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + [[package]] name = "mako" version = "1.3.11" @@ -1752,6 +1901,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "networkx" version = "3.4.2" @@ -1773,17 +1990,22 @@ name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform == 'darwin'", "python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform == 'darwin'", "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.14' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform != 'darwin'", + "(python_full_version >= '3.15' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform != 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", @@ -2219,11 +2441,11 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -2363,6 +2585,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] +[[package]] +name = "pockets" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/8e/0601097cfcce2e8c2297db5080e9719f549c2bd4b94420ddc8d3f848bbca/pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3", size = 24993, upload-time = "2019-11-02T14:46:19.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/2f/a4583c70fbd8cd04910e2884bcc2bdd670e884061f7b4d70bc13e632a993/pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86", size = 26263, upload-time = "2019-11-02T14:46:17.814Z" }, +] + [[package]] name = "portalocker" version = "3.2.0" @@ -3713,6 +3947,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] +[[package]] +name = "sphinxcontrib-napoleon" +version = "0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pockets" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/eb/ad89500f4cee83187596e07f43ad561f293e8e6e96996005c3319653b89f/sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8", size = 21232, upload-time = "2018-09-23T14:16:47.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/f2/6b7627dfe7b4e418e295e254bb15c3a6455f11f8c0ad0d43113f678049c3/sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef", size = 17151, upload-time = "2018-09-23T14:16:45.548Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -3830,17 +4077,22 @@ name = "tifffile" version = "2026.1.28" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform == 'darwin'", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform == 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform == 'darwin'", "python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform == 'darwin'", "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine == 's390x' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.15' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.14' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform != 'darwin'", + "(python_full_version >= '3.15' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version >= '3.15' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "(python_full_version == '3.14.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.14.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version >= '3.15' and platform_machine == 's390x' and sys_platform != 'darwin'", + "python_full_version == '3.14.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform != 'darwin'", "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')", @@ -4069,6 +4321,7 @@ name = "trackers" version = "2.4.0" source = { editable = "." } dependencies = [ + { name = "defopt" }, { name = "numpy" }, { name = "opencv-python" }, { name = "requests" }, @@ -4092,6 +4345,7 @@ build = [ { name = "wheel" }, ] dev = [ + { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "torch" }, @@ -4114,6 +4368,7 @@ mypy-types = [ [package.metadata] requires-dist = [ + { name = "defopt", specifier = ">=7.0.0,<8" }, { name = "inference-models", marker = "extra == 'detection'", specifier = ">=0.19.0" }, { name = "numpy", specifier = ">=2.0.2" }, { name = "opencv-python", specifier = ">=4.8.0" }, @@ -4132,6 +4387,7 @@ build = [ { name = "wheel", specifier = ">=0.40" }, ] dev = [ + { name = "mypy", specifier = ">=2.1.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "torch" },