diff --git a/libraries/getitrack/configs/default.yaml b/libraries/getitrack/configs/default.yaml index ed8f60892c3..ef0684fc057 100644 --- a/libraries/getitrack/configs/default.yaml +++ b/libraries/getitrack/configs/default.yaml @@ -6,7 +6,7 @@ score_threshold: 0.1 class_filter: null # Detection-to-track matching. match_threshold: 0.8 -high_score_threshold: 0.6 +high_score_threshold: 0.5 match_class_only: true lifecycle: max_age: 30 @@ -15,7 +15,7 @@ lifecycle: motion: process_noise: 1.0 measurement_noise: 1.0 - velocity_decay: 0.99 + velocity_decay: 1.0 interpolation: enabled: true method: linear diff --git a/libraries/getitrack/pyproject.toml b/libraries/getitrack/pyproject.toml index 5ceb9cd75cf..5d65dbec47b 100644 --- a/libraries/getitrack/pyproject.toml +++ b/libraries/getitrack/pyproject.toml @@ -21,10 +21,11 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "loguru>=0.7", - "numpy>=2.0", - "pydantic>=2.7", + "loguru~=0.7", + "numpy~=2.0", + "pydantic~=2.7", "pyyaml==6.0.3", + "scipy~=1.13", ] [dependency-groups] diff --git a/libraries/getitrack/src/getitrack/__init__.py b/libraries/getitrack/src/getitrack/__init__.py index 0f71bef5b21..a33dcfa25ff 100644 --- a/libraries/getitrack/src/getitrack/__init__.py +++ b/libraries/getitrack/src/getitrack/__init__.py @@ -2,10 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 """getitrack: Multi-object tracking toolkit.""" -from loguru import logger +from loguru import logger as _logger -# Stay silent until the application opts in via `logger.enable("getitrack")` -# or a tracker's `verbose` flag. -logger.disable("getitrack") +import getitrack.algorithms # noqa: F401 -> registers the bundled algorithms on import + +# Silent by default; the application opts in with ``logger.enable("getitrack")``. +_logger.disable("getitrack") __version__ = "0.1.0" diff --git a/libraries/getitrack/src/getitrack/algorithms/__init__.py b/libraries/getitrack/src/getitrack/algorithms/__init__.py new file mode 100644 index 00000000000..714cae24930 --- /dev/null +++ b/libraries/getitrack/src/getitrack/algorithms/__init__.py @@ -0,0 +1,12 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Concrete tracker algorithms. + +Importing this package registers every algorithm into +`getitrack.core.registry.ALGORITHM_REGISTRY` as a side effect, so that +`BaseTracker.from_config` can dispatch by name without explicit wiring. +""" + +from getitrack.algorithms.bytetrack import ByteTrackTracker + +__all__ = ["ByteTrackTracker"] diff --git a/libraries/getitrack/src/getitrack/algorithms/bytetrack.py b/libraries/getitrack/src/getitrack/algorithms/bytetrack.py new file mode 100644 index 00000000000..f354b4b8ade --- /dev/null +++ b/libraries/getitrack/src/getitrack/algorithms/bytetrack.py @@ -0,0 +1,289 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""ByteTrack: two-stage detection-to-track association. + +Tracks are held in one `_tracks` dict keyed by id, with lifecycle state on each +`Track` and Kalman state on the tracker in `_kalman_states`. Duplicate +suppression is class-aware when `match_class_only` is set. + +Reference: Zhang et al., "ByteTrack: Multi-Object Tracking by Associating +Every Detection Box" (ECCV 2022). +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING, ClassVar + +import numpy as np + +from getitrack.algorithms.configs.bytetrack import ByteTrackConfig +from getitrack.core.base import BaseTracker +from getitrack.core.detection import Detections, TrackedDetections +from getitrack.core.registry import register_algorithm +from getitrack.core.track import Track, TrackState +from getitrack.matching import fuse_score, iou_distance, linear_assignment +from getitrack.motion import KalmanFilter +from getitrack.utils import xyah_to_xyxy, xyxy_to_xyah + +if TYPE_CHECKING: + from getitrack.config import LifecycleConfig + + +def _subset(dets: Detections, indices: list[int] | np.ndarray) -> Detections: + idx = np.asarray(indices, dtype=np.int64) + return Detections( + bboxes=dets.bboxes[idx], + scores=dets.scores[idx], + class_ids=dets.class_ids[idx], + frame_id=dets.frame_id, + embeddings=None if dets.embeddings is None else dets.embeddings[idx], + ) + + +@register_algorithm("bytetrack", config=ByteTrackConfig) +class ByteTrackTracker(BaseTracker[ByteTrackConfig]): + """ByteTrack multi-object tracker. + + Two-stage association: confirmed tracks (ACTIVE + LOST) are matched + against high-score detections with score fusion, then unmatched ACTIVE + tracks are matched against low-score detections to recover from brief + mis-detections. TENTATIVE tracks compete for leftover high-score + detections; a remaining high-score detection spawns a new track once its + score clears the high/low split by 0.1. ``update`` returns the ACTIVE + tracks for the frame. + """ + + algorithm_name: ClassVar[str] = "bytetrack" + + _UNMATCHABLE_COST: ClassVar[np.float32] = np.nextafter(np.float32(1.0), np.float32(2.0)) + # Cost limits for the second-stage and tentative association passes. + _SECOND_STAGE_COST_LIMIT: ClassVar[float] = 0.5 + _TENTATIVE_COST_LIMIT: ClassVar[float] = 0.7 + # IoU distance below which an ACTIVE and a LOST track are treated as duplicates. + _DUPLICATE_IOU_DIST: ClassVar[float] = 0.15 + + def __init__(self, config: ByteTrackConfig) -> None: + super().__init__(config) + self._kalman = KalmanFilter.from_config(config.motion) + self._tracks: dict[int, Track] = {} + self._kalman_states: dict[int, tuple[np.ndarray, np.ndarray]] = {} + self._first_frame_id: int | None = None + # track_id -> row index into this frame's input Detections. + self._frame_det_index: dict[int, int] = {} + + def reset(self) -> None: # noqa: D102 + super().reset() + self._tracks.clear() + self._kalman_states.clear() + self._first_frame_id = None + self._frame_det_index.clear() + + def _update_impl(self, detections: Detections) -> TrackedDetections: + """Run one ByteTrack iteration and return the active set.""" + self._frame_id = detections.frame_id + if self._first_frame_id is None: + self._first_frame_id = detections.frame_id + self._frame_det_index.clear() + cfg = self.config + lifecycle = cfg.lifecycle + + # Partition detections by score: high (score > high_score_threshold) and + # low (score_threshold < score < high_score_threshold). Bounds are strict, + # so scores equal to a threshold fall outside both bands and are dropped. + scores = detections.scores + high_src = np.flatnonzero(scores > cfg.high_score_threshold) + low_src = np.flatnonzero((scores > cfg.score_threshold) & (scores < cfg.high_score_threshold)) + high_dets = _subset(detections, high_src) + low_dets = _subset(detections, low_src) + + self._predict_all() + + tentative_ids = [tid for tid, t in self._tracks.items() if t.state == TrackState.TENTATIVE] + confirmed_ids = [tid for tid, t in self._tracks.items() if t.state in {TrackState.ACTIVE, TrackState.LOST}] + + # First association: confirmed tracks (ACTIVE + LOST) vs high-score detections. + matches_a, unmatched_track_a, unmatched_det_a = self._associate( + confirmed_ids, + high_dets, + cfg.match_threshold, + apply_fuse_score=True, + ) + matched_high_dets: set[int] = set() + for ti, di in matches_a: + self._apply_hit(confirmed_ids[ti], high_dets, di, lifecycle, src_index=int(high_src[di])) + matched_high_dets.add(di) + + # Second association: unmatched ACTIVE tracks vs low-score detections (no fuse_score). + remaining_confirmed_ids = [ + confirmed_ids[i] for i in unmatched_track_a if self._tracks[confirmed_ids[i]].state == TrackState.ACTIVE + ] + matches_b, unmatched_track_b, _ = self._associate( + remaining_confirmed_ids, + low_dets, + cost_limit=self._SECOND_STAGE_COST_LIMIT, + apply_fuse_score=False, + ) + for ti, di in matches_b: + self._apply_hit(remaining_confirmed_ids[ti], low_dets, di, lifecycle, src_index=int(low_src[di])) + + missed_after_b = {remaining_confirmed_ids[i] for i in unmatched_track_b} + confirmed_ids_set_b = set(remaining_confirmed_ids) + for i in unmatched_track_a: + tid = confirmed_ids[i] + if tid not in confirmed_ids_set_b or tid in missed_after_b: + self._tracks[tid].mark_miss(lifecycle) + + # TENTATIVE tracks compete for leftover high-score detections. + leftover_high_idx = [i for i in unmatched_det_a if i not in matched_high_dets] + matches_c, unmatched_track_c, unmatched_det_c = self._associate( + tentative_ids, + _subset(high_dets, leftover_high_idx), + self._TENTATIVE_COST_LIMIT, + apply_fuse_score=True, + ) + for ti, di in matches_c: + real_di = leftover_high_idx[di] + self._apply_hit(tentative_ids[ti], high_dets, real_di, lifecycle, src_index=int(high_src[real_di])) + for i in unmatched_track_c: + tid = tentative_ids[i] + self._tracks[tid].mark_miss(lifecycle) + + # A new track needs a score clear of the high/low split by the margin. + new_track_floor = cfg.new_track_floor + for di in unmatched_det_c: + real_di = leftover_high_idx[di] + if float(high_dets.scores[real_di]) >= new_track_floor: + self._spawn(high_dets, real_di, src_index=int(high_src[real_di])) + + for tid in list(self._tracks): + if self._tracks[tid].should_remove: + del self._tracks[tid] + self._kalman_states.pop(tid, None) + + self._remove_duplicate_tracks() + return self._compose_output(detections.frame_id) + + def _predict_all(self) -> None: + if not self._kalman_states: + return + tids = list(self._kalman_states) + means = np.stack([self._kalman_states[tid][0] for tid in tids], axis=0) + covs = np.stack([self._kalman_states[tid][1] for tid in tids], axis=0) + # Lost tracks: zero out the height velocity so missing observations do not blow up the box. + for i, tid in enumerate(tids): + if self._tracks[tid].state != TrackState.ACTIVE: + means[i, 7] = 0.0 + means, covs = self._kalman.multi_predict(means, covs) + for i, tid in enumerate(tids): + self._kalman_states[tid] = (means[i], covs[i]) + self._tracks[tid].bbox = xyah_to_xyxy(means[i, :4][None, :])[0].astype(np.float32) + + def _associate( + self, + track_ids: list[int], + dets: Detections, + cost_limit: float, + *, + apply_fuse_score: bool, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + if not track_ids or len(dets) == 0: + return ( + np.empty((0, 2), dtype=np.int64), + np.arange(len(track_ids), dtype=np.int64), + np.arange(len(dets), dtype=np.int64), + ) + track_boxes = np.stack([self._tracks[tid].bbox for tid in track_ids], axis=0) + cost = iou_distance(track_boxes, dets.bboxes) + cls_mismatch: np.ndarray | None = None + if self.config.match_class_only: + track_classes = np.array([self._tracks[tid].class_id for tid in track_ids]) + cls_mismatch = track_classes[:, None] != dets.class_ids[None, :] + if apply_fuse_score: + cost = fuse_score(cost, dets.scores) + if cls_mismatch is not None: + cost[cls_mismatch] = self._UNMATCHABLE_COST + return linear_assignment(cost, cost_limit) + + def _apply_hit( + self, + track_id: int, + dets: Detections, + det_idx: int, + lifecycle: LifecycleConfig, + *, + src_index: int, + ) -> None: + track = self._tracks[track_id] + bbox = dets.bboxes[det_idx] + score = float(dets.scores[det_idx]) + track.mark_hit(bbox, score, lifecycle) + self._frame_det_index[track_id] = src_index + measurement = xyxy_to_xyah(bbox[None, :])[0] + mean, covariance = self._kalman_states[track_id] + self._kalman_states[track_id] = self._kalman.update(mean, covariance, measurement) + + def _spawn(self, dets: Detections, det_idx: int, *, src_index: int) -> None: + track_id = self._allocate_id() + bbox = dets.bboxes[det_idx].astype(np.float32) + class_id = int(dets.class_ids[det_idx]) + score = float(dets.scores[det_idx]) + # Tracks activate immediately when min_hits <= 1 or on the first frame. + skip_tentative = self.config.lifecycle.min_hits <= 1 or dets.frame_id == self._first_frame_id + initial_state = TrackState.ACTIVE if skip_tentative else TrackState.TENTATIVE + track = Track( + track_id=track_id, + class_id=class_id, + bbox=bbox, + score=score, + state=initial_state, + _start_frame=dets.frame_id, + ) + self._tracks[track_id] = track + self._frame_det_index[track_id] = src_index + measurement = xyxy_to_xyah(bbox[None, :])[0] + self._kalman_states[track_id] = self._kalman.initiate(measurement) + + def _remove_duplicate_tracks(self) -> None: + """Drop the younger of any ACTIVE/LOST track pair with near-identical boxes. + + The longer-lived track is kept. Cross-class pairs are skipped when + ``match_class_only`` is set, since they are distinct objects. + """ + active = [tid for tid, t in self._tracks.items() if t.state == TrackState.ACTIVE] + lost = [tid for tid, t in self._tracks.items() if t.state == TrackState.LOST] + if not active or not lost: + return + dist = iou_distance( + np.stack([self._tracks[tid].bbox for tid in active], axis=0), + np.stack([self._tracks[tid].bbox for tid in lost], axis=0), + ) + # Collect duplicates first, then remove, so a track shared across + # several pairs is not popped mid-iteration. + drop_ids: set[int] = set() + for ai, li in np.argwhere(dist < self._DUPLICATE_IOU_DIST): + a_track, l_track = self._tracks[active[ai]], self._tracks[lost[li]] + if self.config.match_class_only and a_track.class_id != l_track.class_id: + continue + # Reference tie-break: keep the strictly longer-lived track, so an + # equal-age pair drops the active-side track. + drop_ids.add(lost[li] if a_track.age > l_track.age else active[ai]) + for tid in drop_ids: + self._tracks.pop(tid, None) + self._kalman_states.pop(tid, None) + + def _compose_output(self, frame_id: int) -> TrackedDetections: + # Output is ACTIVE tracks only; LOST tracks coast internally. + active = [t for t in self._tracks.values() if t.state == TrackState.ACTIVE] + if not active: + empty = TrackedDetections.create_empty(frame_id=frame_id) + return replace(empty, det_indices=np.empty((0,), dtype=np.int64)) + return TrackedDetections( + bboxes=np.stack([t.bbox for t in active], axis=0).astype(np.float32), + scores=np.array([t.score for t in active], dtype=np.float32), + class_ids=np.array([t.class_id for t in active], dtype=np.int64), + track_ids=np.array([t.track_id for t in active], dtype=np.int64), + track_states=np.array([int(t.state) for t in active], dtype=np.int8), + frame_id=frame_id, + det_indices=np.array([self._frame_det_index.get(t.track_id, -1) for t in active], dtype=np.int64), + ) diff --git a/libraries/getitrack/src/getitrack/algorithms/configs/__init__.py b/libraries/getitrack/src/getitrack/algorithms/configs/__init__.py new file mode 100644 index 00000000000..9a90e8d14e5 --- /dev/null +++ b/libraries/getitrack/src/getitrack/algorithms/configs/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Per-tracker configuration models.""" diff --git a/libraries/getitrack/src/getitrack/algorithms/configs/bytetrack.py b/libraries/getitrack/src/getitrack/algorithms/configs/bytetrack.py new file mode 100644 index 00000000000..5ddee7a0d82 --- /dev/null +++ b/libraries/getitrack/src/getitrack/algorithms/configs/bytetrack.py @@ -0,0 +1,51 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Configuration model for the ByteTrack tracker.""" + +from __future__ import annotations + +from typing import Annotated, ClassVar, Literal + +from pydantic import Field, model_validator + +from getitrack.config import AlgorithmType, TrackerConfig + + +class ByteTrackConfig(TrackerConfig): + """ByteTrack-specific configuration.""" + + # Margin above the high/low split a detection must clear to spawn a new track. + _NEW_TRACK_MARGIN: ClassVar[float] = 0.1 + + # Literal pins the value in the JSON schema and typing. + algorithm: Literal[AlgorithmType.BYTETRACK] = AlgorithmType.BYTETRACK # pyrefly: ignore[bad-override] + """Algorithm identifier; fixed to ``bytetrack``.""" + + match_threshold: Annotated[float, Field(ge=0.0, le=1.0)] = 0.8 + """Maximum assignment cost accepted when matching detections to tracks. + The cost is ``1 - IoU``, score-fused to ``1 - IoU * score`` where fusion + applies, so larger values accept weaker overlaps.""" + + high_score_threshold: Annotated[float, Field(ge=0.0, le=1.0)] = 0.5 + """High/low detection split for two-stage association (ByteTrack's ``track_thresh``). + Spawning a new track additionally requires a score 0.1 above this value.""" + + match_class_only: bool = True + """Restrict matching to detection-track pairs that share a class id.""" + + @property + def new_track_floor(self) -> float: + """Minimum score for an unmatched high detection to spawn a new track.""" + return self.high_score_threshold + self._NEW_TRACK_MARGIN + + @model_validator(mode="after") + def _check_thresholds(self) -> ByteTrackConfig: + """Reject thresholds that contradict the high/low split or new-track gate.""" + if self.score_threshold >= self.high_score_threshold: + msg = "score_threshold must be below high_score_threshold" + raise ValueError(msg) + if self.new_track_floor > 1.0: + ceiling = 1.0 - self._NEW_TRACK_MARGIN + msg = f"high_score_threshold must be <= {ceiling} to leave room for the new-track margin" + raise ValueError(msg) + return self diff --git a/libraries/getitrack/src/getitrack/config.py b/libraries/getitrack/src/getitrack/config.py index a0a162dd85a..bc7f020f093 100644 --- a/libraries/getitrack/src/getitrack/config.py +++ b/libraries/getitrack/src/getitrack/config.py @@ -68,8 +68,9 @@ class MotionConfig(_StrictModel): measurement_noise: Annotated[float, Field(gt=0.0)] = 1.0 """Multiplier on the measurement-noise covariance (R). Larger values weight the motion prior over observations.""" - velocity_decay: Annotated[float, Field(gt=0.0, le=1.0)] = 0.99 - """Per-frame velocity damping in ``(0, 1]``. Values below 1.0 simulate gradual deceleration.""" + velocity_decay: Annotated[float, Field(gt=0.0, le=1.0)] = 1.0 + """Per-frame velocity damping in ``(0, 1]``. The default 1.0 matches the + reference (no damping); values below 1.0 simulate gradual deceleration.""" class InterpolationConfig(_StrictModel): @@ -111,7 +112,7 @@ class TrackerConfig(_StrictModel): match tracks.""" score_threshold: Annotated[float, Field(ge=0.0, le=1.0)] = 0.1 - """Minimum detection confidence the tracking algorithm considers; lower-scoring detections are excluded.""" + """Low-score floor for tracking; detections scoring at or below this are excluded.""" lifecycle: LifecycleConfig = Field(default_factory=LifecycleConfig) """Track creation, confirmation, and removal parameters.""" diff --git a/libraries/getitrack/src/getitrack/core/detection.py b/libraries/getitrack/src/getitrack/core/detection.py index 53b02da89d9..519c13b17ce 100644 --- a/libraries/getitrack/src/getitrack/core/detection.py +++ b/libraries/getitrack/src/getitrack/core/detection.py @@ -88,10 +88,7 @@ def filter_by_score(self, threshold: float) -> Detections: return self._index(keep) def split_by_score(self, threshold: float) -> tuple[Detections, Detections]: - """Return ``(high, low)`` partitions split at ``threshold``. - - Used by ByteTrack's two-stage association. - """ + """Return ``(high, low)`` partitions split at ``threshold`` (``high`` is ``>=``).""" high = self.scores >= threshold return self._index(high), self._index(~high) diff --git a/libraries/getitrack/src/getitrack/matching/__init__.py b/libraries/getitrack/src/getitrack/matching/__init__.py new file mode 100644 index 00000000000..32ec1b8af97 --- /dev/null +++ b/libraries/getitrack/src/getitrack/matching/__init__.py @@ -0,0 +1,12 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Detection-to-track association utilities (IoU + assignment).""" + +from getitrack.matching.iou import ( + fuse_score, + iou_distance, + iou_matrix, + linear_assignment, +) + +__all__ = ["fuse_score", "iou_distance", "iou_matrix", "linear_assignment"] diff --git a/libraries/getitrack/src/getitrack/matching/iou.py b/libraries/getitrack/src/getitrack/matching/iou.py new file mode 100644 index 00000000000..8e7d52231e0 --- /dev/null +++ b/libraries/getitrack/src/getitrack/matching/iou.py @@ -0,0 +1,156 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""IoU computation and Hungarian linear assignment. + +All functions operate on plain numpy arrays in ``xyxy`` format. Assignment +uses `scipy.optimize.linear_sum_assignment`. +""" + +from __future__ import annotations + +import numpy as np +from scipy.optimize import linear_sum_assignment + +_BBOX_COLS = 4 +_EPS = 1e-7 +# Sentinel cost for over-threshold pairs, far above any valid cost in [0, 1]. +_INVALID_COST = 1e6 + + +def _check_shape(arr: np.ndarray, name: str) -> None: + if arr.ndim != 2 or arr.shape[1] != _BBOX_COLS: + msg = f"{name} must have shape (N, {_BBOX_COLS}); got {arr.shape}" + raise ValueError(msg) + + +def _check_finite(arr: np.ndarray, name: str) -> None: + if arr.size and not np.isfinite(arr).all(): + msg = f"{name} contains non-finite values (NaN or inf)" + raise ValueError(msg) + + +def iou_matrix(boxes_a: np.ndarray, boxes_b: np.ndarray) -> np.ndarray: + """Compute pairwise IoU between two sets of ``xyxy`` boxes. + + Args: + boxes_a: ``(M, 4)`` float array in ``[x1, y1, x2, y2]`` order. + boxes_b: ``(N, 4)`` float array in ``[x1, y1, x2, y2]`` order. + + Returns: + ``(M, N)`` float32 matrix of IoU values in ``[0, 1]``. + + Raises: + ValueError: If either input is not 2-D with 4 columns, or + contains non-finite values (NaN, inf). + """ + _check_shape(boxes_a, "boxes_a") + _check_shape(boxes_b, "boxes_b") + _check_finite(boxes_a, "boxes_a") + _check_finite(boxes_b, "boxes_b") + m, n = boxes_a.shape[0], boxes_b.shape[0] + if m == 0 or n == 0: + return np.zeros((m, n), dtype=np.float32) + + a = boxes_a.astype(np.float32, copy=False) + b = boxes_b.astype(np.float32, copy=False) + x1a, y1a, x2a, y2a = a.T + x1b, y1b, x2b, y2b = b.T + + # Preallocate the four (M, N) buffers we need and reuse the `max` + # buffers to hold `inter_w` / `inter_h` after the subtraction step. + x_min_i = np.empty((m, n), dtype=np.float32) + y_min_i = np.empty_like(x_min_i) + inter_w = np.empty_like(x_min_i) + inter_h = np.empty_like(x_min_i) + + np.maximum(x1a[:, None], x1b[None, :], out=x_min_i) + np.minimum(x2a[:, None], x2b[None, :], out=inter_w) + np.maximum(y1a[:, None], y1b[None, :], out=y_min_i) + np.minimum(y2a[:, None], y2b[None, :], out=inter_h) + np.subtract(inter_w, x_min_i, out=inter_w) + np.subtract(inter_h, y_min_i, out=inter_h) + np.clip(inter_w, 0.0, None, out=inter_w) + np.clip(inter_h, 0.0, None, out=inter_h) + + inter = inter_w * inter_h + area_a = (x2a - x1a) * (y2a - y1a) + area_b = (x2b - x1b) * (y2b - y1b) + union = area_a[:, None] + area_b[None, :] - inter + return (inter / np.maximum(union, _EPS)).astype(np.float32) + + +def iou_distance(boxes_a: np.ndarray, boxes_b: np.ndarray) -> np.ndarray: + """Compute pairwise IoU cost (``1 - IoU``) between two sets of boxes. + + Args: + boxes_a: ``(M, 4)`` ``xyxy`` array. + boxes_b: ``(N, 4)`` ``xyxy`` array. + + Returns: + ``(M, N)`` float32 cost matrix in ``[0, 1]``. + """ + return 1.0 - iou_matrix(boxes_a, boxes_b) + + +def fuse_score(cost_matrix: np.ndarray, det_scores: np.ndarray) -> np.ndarray: + """Weight an IoU cost matrix by per-detection confidence. + + The returned cost is ``1 - (IoU * score)``, so high-confidence + detections become cheaper to match during assignment. + + Args: + cost_matrix: ``(M, N)`` IoU cost matrix (``1 - IoU``). + det_scores: ``(N,)`` detection confidences in ``[0, 1]``. + + Returns: + ``(M, N)`` fused cost matrix in ``[0, 1]``. + """ + if cost_matrix.size == 0: + return cost_matrix + iou_sim = 1.0 - cost_matrix + scores = np.asarray(det_scores, dtype=np.float32)[None, :] + return (1.0 - iou_sim * scores).astype(np.float32) + + +def linear_assignment( + cost_matrix: np.ndarray, + thresh: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Solve Hungarian assignment gated by a maximum-cost threshold. + + Pairs with ``cost > thresh`` are masked to a large sentinel before solving, + so they cannot displace a valid match in the optimal assignment. + + Args: + cost_matrix: ``(M, N)`` cost matrix. Rows are typically tracks, + columns typically detections. + thresh: Inclusive maximum cost per matched pair. + + Returns: + A tuple ``(matches, unmatched_rows, unmatched_cols)``: + + - ``matches``: ``(K, 2)`` int array of ``(row, col)`` pairs that + satisfy the threshold. + - ``unmatched_rows``: int array of row indices left unmatched. + - ``unmatched_cols``: int array of column indices left unmatched. + """ + n_rows, n_cols = cost_matrix.shape + if cost_matrix.size == 0: + return ( + np.empty((0, 2), dtype=np.int64), + np.arange(n_rows, dtype=np.int64), + np.arange(n_cols, dtype=np.int64), + ) + + gated = np.where(cost_matrix > thresh, _INVALID_COST, cost_matrix) + row_ind, col_ind = linear_sum_assignment(gated) + keep = cost_matrix[row_ind, col_ind] <= thresh + matched_rows = row_ind[keep] + matched_cols = col_ind[keep] + + matches = np.stack([matched_rows, matched_cols], axis=1).astype(np.int64) + matched_row_set = set(matched_rows.tolist()) + matched_col_set = set(matched_cols.tolist()) + unmatched_rows = np.array([i for i in range(n_rows) if i not in matched_row_set], dtype=np.int64) + unmatched_cols = np.array([j for j in range(n_cols) if j not in matched_col_set], dtype=np.int64) + return matches, unmatched_rows, unmatched_cols diff --git a/libraries/getitrack/src/getitrack/motion/__init__.py b/libraries/getitrack/src/getitrack/motion/__init__.py new file mode 100644 index 00000000000..1ac8d83a17f --- /dev/null +++ b/libraries/getitrack/src/getitrack/motion/__init__.py @@ -0,0 +1,7 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Motion models (Kalman filter).""" + +from getitrack.motion.kalman import KalmanFilter + +__all__ = ["KalmanFilter"] diff --git a/libraries/getitrack/src/getitrack/motion/kalman.py b/libraries/getitrack/src/getitrack/motion/kalman.py new file mode 100644 index 00000000000..c4fea2c4127 --- /dev/null +++ b/libraries/getitrack/src/getitrack/motion/kalman.py @@ -0,0 +1,215 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Constant-velocity Kalman filter for bounding-box tracking. + +State space (8-dim): ``(x, y, a, h, vx, vy, va, vh)`` where ``(x, y)`` is +the box center, ``a`` is width/height, ``h`` is height, and the rest are +per-frame velocities. Internal state is ``xyah``; convert to and from ``xyxy`` +with `getitrack.utils.xyxy_to_xyah` / `xyah_to_xyxy`. + +`MotionConfig` scales the process and measurement noise and sets a per-frame +velocity-decay factor (1.0 applies no damping; values below 1.0 damp velocity). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +import numpy as np +import scipy.linalg + +if TYPE_CHECKING: + from getitrack.config import MotionConfig + + +class KalmanFilter: + """Constant-velocity Kalman filter operating on ``xyah`` measurements. + + The filter is stateless. Callers pass ``(mean, covariance)`` tuples + through `predict` and `update` and persist them per-track externally, + so a single `KalmanFilter` instance can serve any number of tracks. + + Attributes: + process_noise: Multiplier on the predict-step process-noise + covariance (Q matrix). + measurement_noise: Multiplier on the update-step measurement-noise + covariance (R matrix). + velocity_decay: Per-frame velocity damping in ``(0, 1]``. + """ + + _NDIM: ClassVar[int] = 4 + _STATE_DIM: ClassVar[int] = 2 * _NDIM + _STD_WEIGHT_POSITION: ClassVar[float] = 1.0 / 20.0 + _STD_WEIGHT_VELOCITY: ClassVar[float] = 1.0 / 160.0 + _DEFAULT_VELOCITY_DECAY: ClassVar[float] = 1.0 + + def __init__( + self, + process_noise: float = 1.0, + measurement_noise: float = 1.0, + velocity_decay: float | None = None, + ) -> None: + self.process_noise = process_noise + self.measurement_noise = measurement_noise + self.velocity_decay = self._DEFAULT_VELOCITY_DECAY if velocity_decay is None else velocity_decay + + self._motion_mat = np.eye(self._STATE_DIM, self._STATE_DIM) + for i in range(self._NDIM): + self._motion_mat[i, self._NDIM + i] = 1.0 + if self.velocity_decay != self._DEFAULT_VELOCITY_DECAY: + for i in range(self._NDIM): + self._motion_mat[self._NDIM + i, self._NDIM + i] = self.velocity_decay + + self._update_mat = np.eye(self._NDIM, self._STATE_DIM) + + @classmethod + def from_config(cls, config: MotionConfig) -> KalmanFilter: + """Construct a `KalmanFilter` from a validated `MotionConfig`.""" + return cls( + process_noise=config.process_noise, + measurement_noise=config.measurement_noise, + velocity_decay=config.velocity_decay, + ) + + def initiate(self, measurement: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Initialise a track's state from one ``xyah`` measurement. + + Args: + measurement: ``(4,)`` ``[x, y, a, h]`` array. + + Returns: + ``(mean, covariance)`` where ``mean`` has shape ``(8,)`` and + ``covariance`` has shape ``(8, 8)``. Velocity components start + at zero with broad covariance. + """ + mean_pos = np.asarray(measurement, dtype=np.float64) + mean_vel = np.zeros_like(mean_pos) + mean = np.r_[mean_pos, mean_vel] + + h = mean_pos[3] + std = np.array( + [ + 2 * self._STD_WEIGHT_POSITION * h, + 2 * self._STD_WEIGHT_POSITION * h, + 1e-2, + 2 * self._STD_WEIGHT_POSITION * h, + 10 * self._STD_WEIGHT_VELOCITY * h, + 10 * self._STD_WEIGHT_VELOCITY * h, + 1e-5, + 10 * self._STD_WEIGHT_VELOCITY * h, + ], + ) + covariance = np.diag(np.square(std)) + return mean, covariance + + def predict(self, mean: np.ndarray, covariance: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Propagate one step forward under the constant-velocity model. + + Args: + mean: ``(8,)`` prior mean. + covariance: ``(8, 8)`` prior covariance. + + Returns: + ``(mean, covariance)`` of the propagated state. + """ + motion_cov = self._predict_noise_cov(mean[3]) + new_mean = self._motion_mat @ mean + new_covariance = self._motion_mat @ covariance @ self._motion_mat.T + motion_cov + return new_mean, new_covariance + + def multi_predict( + self, + means: np.ndarray, + covariances: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + """Propagate ``N`` tracks in a single batched call. + + Args: + means: ``(N, 8)`` stacked priors. + covariances: ``(N, 8, 8)`` stacked prior covariances. + + Returns: + ``(means, covariances)`` of the propagated states. + """ + if means.shape[0] == 0: + return means, covariances + motion_covs = np.stack([self._predict_noise_cov(h) for h in means[:, 3]], axis=0) + # einsum avoids a macOS Accelerate batched-matmul path that raises + # spurious floating-point warnings. + new_means = np.einsum("nj,ij->ni", means, self._motion_mat) + left = np.einsum("ij,njk->nik", self._motion_mat, covariances) + new_covariances = np.einsum("nij,kj->nik", left, self._motion_mat) + motion_covs + return new_means, new_covariances + + def project(self, mean: np.ndarray, covariance: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Project the state distribution into measurement space. + + Args: + mean: ``(8,)`` state mean. + covariance: ``(8, 8)`` state covariance. + + Returns: + ``(measurement_mean, measurement_covariance)`` of shapes + ``(4,)`` and ``(4, 4)``. + """ + innovation_cov = self._measurement_noise_cov(mean[3]) + m = self._update_mat @ mean + c = self._update_mat @ covariance @ self._update_mat.T + return m, c + innovation_cov + + def update( + self, + mean: np.ndarray, + covariance: np.ndarray, + measurement: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + """Apply the Kalman correction step with a new ``xyah`` measurement. + + Args: + mean: ``(8,)`` predicted state mean. + covariance: ``(8, 8)`` predicted state covariance. + measurement: ``(4,)`` observed ``[x, y, a, h]``. + + Returns: + ``(mean, covariance)`` of the corrected posterior. + """ + projected_mean, projected_cov = self.project(mean, covariance) + chol_factor, lower = scipy.linalg.cho_factor(projected_cov, lower=True, check_finite=False) + kalman_gain_t = np.asarray( + scipy.linalg.cho_solve( + (chol_factor, lower), + (covariance @ self._update_mat.T).T, + check_finite=False, + ), + ) + kalman_gain = kalman_gain_t.T + innovation = measurement - projected_mean + new_mean = mean + kalman_gain @ innovation + new_covariance = covariance - kalman_gain @ projected_cov @ kalman_gain.T + return new_mean, new_covariance + + def _predict_noise_cov(self, h: float) -> np.ndarray: + std = np.array( + [ + self._STD_WEIGHT_POSITION * h, + self._STD_WEIGHT_POSITION * h, + 1e-2, + self._STD_WEIGHT_POSITION * h, + self._STD_WEIGHT_VELOCITY * h, + self._STD_WEIGHT_VELOCITY * h, + 1e-5, + self._STD_WEIGHT_VELOCITY * h, + ], + ) + return np.diag(np.square(std)) * self.process_noise + + def _measurement_noise_cov(self, h: float) -> np.ndarray: + std = np.array( + [ + self._STD_WEIGHT_POSITION * h, + self._STD_WEIGHT_POSITION * h, + 1e-1, + self._STD_WEIGHT_POSITION * h, + ], + ) + return np.diag(np.square(std)) * self.measurement_noise diff --git a/libraries/getitrack/src/getitrack/utils.py b/libraries/getitrack/src/getitrack/utils.py new file mode 100644 index 00000000000..cf1a3854c3e --- /dev/null +++ b/libraries/getitrack/src/getitrack/utils.py @@ -0,0 +1,55 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Bounding-box coordinate conversions. + +``xyah`` is the ``(center_x, center_y, aspect, height)`` form used by the +Kalman filter; ``xyxy`` is ``(x1, y1, x2, y2)``. +""" + +from __future__ import annotations + +import numpy as np + + +def xyxy_to_xyah(boxes: np.ndarray) -> np.ndarray: + """Convert ``xyxy`` boxes to ``xyah`` form. + + Args: + boxes: ``(N, 4)`` array in ``[x1, y1, x2, y2]``. Must satisfy + ``x2 > x1`` and ``y2 > y1`` for every row. + + Returns: + ``(N, 4)`` array in ``[cx, cy, aspect, height]``. + + Raises: + ValueError: If any box has zero or negative width or height. + """ + boxes = np.atleast_2d(np.asarray(boxes, dtype=np.float64)) + width = boxes[:, 2] - boxes[:, 0] + height = boxes[:, 3] - boxes[:, 1] + if np.any(width <= 0) or np.any(height <= 0): + msg = "xyxy_to_xyah requires positive width and height (x2 > x1, y2 > y1)" + raise ValueError(msg) + cx = boxes[:, 0] + width / 2.0 + cy = boxes[:, 1] + height / 2.0 + aspect = width / height + return np.stack([cx, cy, aspect, height], axis=1) + + +def xyah_to_xyxy(boxes: np.ndarray) -> np.ndarray: + """Convert ``xyah`` boxes back to ``xyxy`` form. + + Args: + boxes: ``(N, 4)`` array in ``[cx, cy, aspect, height]``. + + Returns: + ``(N, 4)`` array in ``[x1, y1, x2, y2]``. + """ + boxes = np.atleast_2d(np.asarray(boxes, dtype=np.float64)) + cx, cy, aspect, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] + w = aspect * h + x1 = cx - w / 2.0 + y1 = cy - h / 2.0 + x2 = cx + w / 2.0 + y2 = cy + h / 2.0 + return np.stack([x1, y1, x2, y2], axis=1) diff --git a/libraries/getitrack/tests/unit/test_bytetrack.py b/libraries/getitrack/tests/unit/test_bytetrack.py new file mode 100644 index 00000000000..d6ce90665e9 --- /dev/null +++ b/libraries/getitrack/tests/unit/test_bytetrack.py @@ -0,0 +1,236 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Unit + integration tests for the ByteTrack algorithm.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from pydantic import ValidationError + +import getitrack.algorithms # noqa: F401 -> registers ByteTrack +from getitrack.algorithms import ByteTrackTracker +from getitrack.algorithms.bytetrack import _subset +from getitrack.algorithms.configs.bytetrack import ByteTrackConfig +from getitrack.config import LifecycleConfig, TrackerConfig +from getitrack.core.base import BaseTracker +from getitrack.core.detection import Detections +from getitrack.core.track import Track, TrackState + + +def _dets( + boxes: list[list[float]], scores: list[float], frame_id: int, class_ids: list[int] | None = None +) -> Detections: + n = len(boxes) + return Detections( + bboxes=np.asarray(boxes, dtype=np.float32).reshape(n, 4), + scores=np.asarray(scores, dtype=np.float32), + class_ids=np.asarray(class_ids if class_ids is not None else [0] * n, dtype=np.int64), + frame_id=frame_id, + ) + + +@pytest.fixture +def fast_confirm_config() -> ByteTrackConfig: + # Confirm after 1 hit so single-frame tests don't have to spam frames. + return ByteTrackConfig(lifecycle=LifecycleConfig(min_hits=1, tentative_max_age=1, max_age=5)) + + +class TestRegistry: + def test_from_config_dispatches_to_bytetrack(self): + cfg = ByteTrackConfig() + tracker = BaseTracker.from_config(cfg) + assert isinstance(tracker, ByteTrackTracker) + + +class TestSingleObject: + def test_id_persists_across_frames(self, fast_confirm_config): + bt = ByteTrackTracker(fast_confirm_config) + out0 = bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + out = bt.update(_dets([[12, 12, 52, 52]], [0.9], frame_id=1)) + assert out0.track_ids.tolist() == [1] + assert len(out) == 1 + assert out.track_ids[0] == 1 + + +class TestMultiObject: + def test_class_only_matching_keeps_classes_separate(self): + cfg = ByteTrackConfig(lifecycle=LifecycleConfig(min_hits=1, tentative_max_age=1)) + bt = ByteTrackTracker(cfg) + bt.update(_dets([[0, 0, 20, 20]], [0.9], frame_id=0, class_ids=[0])) + # Same location, different class -> a new id, not a match. + out = bt.update(_dets([[0, 0, 20, 20]], [0.9], frame_id=1, class_ids=[1])) + assert sorted(out.track_ids.tolist()) == [2] + + +class TestLowScoreRecovery: + def test_low_score_detection_keeps_track_active(self): + cfg = ByteTrackConfig(lifecycle=LifecycleConfig(min_hits=1, tentative_max_age=1)) + bt = ByteTrackTracker(cfg) + bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + # Only a low-score detection on frame 1; first-pass would miss it but second-pass picks it up. + out = bt.update(_dets([[12, 12, 52, 52]], [0.3], frame_id=1)) + assert len(out) == 1 + assert out.track_ids[0] == 1 + + +class TestConfigValidation: + def test_low_floor_above_high_split_rejected(self): + with pytest.raises(ValidationError, match="score_threshold must be below"): + ByteTrackConfig(score_threshold=0.7, high_score_threshold=0.5) + + def test_high_split_without_room_for_margin_rejected(self): + with pytest.raises(ValidationError, match="new-track margin"): + ByteTrackConfig(high_score_threshold=0.95) + + def test_default_yaml_matches_programmatic_defaults(self): + path = Path(__file__).resolve().parents[2] / "configs" / "default.yaml" + assert TrackerConfig.from_yaml(path) == ByteTrackConfig() + + +class TestThresholdSemantics: + def test_mid_score_detection_reactivates_lost_track(self): + # A 0.55 detection is above the 0.5 high/low split, so it reaches the + # first-pass association and recovers a LOST track, even though it is + # below the 0.6 new-track threshold. + bt = ByteTrackTracker(ByteTrackConfig(lifecycle=LifecycleConfig(min_hits=1, max_age=5))) + bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + bt.update(_dets([], [], frame_id=1)) # miss -> LOST + out = bt.update(_dets([[12, 12, 52, 52]], [0.55], frame_id=2)) + assert out.track_ids.tolist() == [1] + + def test_high_detection_below_margin_does_not_spawn(self): + # 0.55 clears the split but not the split + 0.1 new-track margin, so with + # no track to match it spawns nothing. + bt = ByteTrackTracker(ByteTrackConfig(lifecycle=LifecycleConfig(min_hits=1))) + out = bt.update(_dets([[10, 10, 50, 50]], [0.55], frame_id=0)) + assert len(out) == 0 + + +class TestDuplicateSuppression: + def test_active_duplicated_against_multiple_lost_does_not_crash(self): + bt = ByteTrackTracker(ByteTrackConfig()) + box = np.array([0, 0, 40, 40], dtype=np.float32) + bt._tracks = { + 1: Track(track_id=1, class_id=0, bbox=box, score=0.9, state=TrackState.LOST, age=10), + 2: Track(track_id=2, class_id=0, bbox=box.copy(), score=0.9, state=TrackState.LOST, age=10), + 3: Track(track_id=3, class_id=0, bbox=box.copy(), score=0.9, state=TrackState.ACTIVE, age=1), + } + bt._remove_duplicate_tracks() + # Track 3 duplicates both LOST tracks; it is dropped once, no KeyError. + assert set(bt._tracks) == {1, 2} + + def test_equal_age_tie_drops_active_track(self): + bt = ByteTrackTracker(ByteTrackConfig()) + box = np.array([0, 0, 40, 40], dtype=np.float32) + bt._tracks = { + 1: Track(track_id=1, class_id=0, bbox=box, score=0.9, state=TrackState.LOST, age=5), + 2: Track(track_id=2, class_id=0, bbox=box.copy(), score=0.9, state=TrackState.ACTIVE, age=5), + } + bt._remove_duplicate_tracks() + # On an age tie the reference drops the active-side track. + assert set(bt._tracks) == {1} + + def test_overlapping_pair_of_different_classes_is_kept(self): + bt = ByteTrackTracker(ByteTrackConfig()) + box = np.array([0, 0, 40, 40], dtype=np.float32) + bt._tracks = { + 1: Track(track_id=1, class_id=0, bbox=box, score=0.9, state=TrackState.LOST, age=10), + 2: Track(track_id=2, class_id=1, bbox=box.copy(), score=0.9, state=TrackState.ACTIVE, age=1), + } + bt._remove_duplicate_tracks() + assert set(bt._tracks) == {1, 2} + + +class TestEmpty: + def test_reset_clears_state(self, fast_confirm_config): + bt = ByteTrackTracker(fast_confirm_config) + bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + bt.reset() + out = bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + # Id allocator is back to 1 after reset. + assert out.track_ids[0] == 1 + + +class TestLifecycle: + def test_aging_out_removes_track(self): + cfg = ByteTrackConfig(lifecycle=LifecycleConfig(min_hits=1, tentative_max_age=1, max_age=2)) + bt = ByteTrackTracker(cfg) + bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + # Three empty frames: state goes ACTIVE -> LOST (frame 1) -> still LOST (2) -> REMOVED (3). + for f in range(1, 4): + bt.update(_dets([], [], frame_id=f)) + # A fresh detection now should spawn track id 2, not reuse 1. + out = bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=4)) + assert out.track_ids[0] != 1 + + +class TestStandardLifecycleDefaults: + """Default config reproduces reference ByteTrack confirmation behavior.""" + + def test_mid_sequence_track_confirms_on_second_match(self): + bt = ByteTrackTracker(ByteTrackConfig()) + # min_hits=2 default, but tracks born on the first frame bypass TENTATIVE. + out0 = bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + assert len(out0) == 1 + # New object on frame 1 starts TENTATIVE: not output yet. + out1 = bt.update(_dets([[10, 10, 50, 50], [200, 200, 240, 240]], [0.9, 0.9], frame_id=1)) + assert len(out1) == 1 + # Its second match confirms it. + out2 = bt.update(_dets([[10, 10, 50, 50], [202, 202, 242, 242]], [0.9, 0.9], frame_id=2)) + assert len(out2) == 2 + + def test_tentative_removed_on_first_miss(self): + # tentative_max_age=0 default: one missed frame kills an unconfirmed track. + bt = ByteTrackTracker(ByteTrackConfig()) + bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + bt.update(_dets([[10, 10, 50, 50], [200, 200, 240, 240]], [0.9, 0.9], frame_id=1)) + bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=2)) + # The object reappears and confirms; it must carry a fresh id, not 2. + bt.update(_dets([[10, 10, 50, 50], [200, 200, 240, 240]], [0.9, 0.9], frame_id=3)) + out = bt.update(_dets([[10, 10, 50, 50], [200, 200, 240, 240]], [0.9, 0.9], frame_id=4)) + assert sorted(out.track_ids.tolist()) == [1, 3] + + +class TestDetIndices: + def test_det_indices_point_to_original_rows_for_low_score_matches(self): + cfg = ByteTrackConfig(lifecycle=LifecycleConfig(min_hits=1, tentative_max_age=1)) + bt = ByteTrackTracker(cfg) + bt.update(_dets([[10, 10, 50, 50]], [0.9], frame_id=0)) + # Row 0 is a low-score continuation; row 1 is an unrelated high-score det. + dets1 = _dets([[12, 12, 52, 52], [300, 300, 340, 340]], [0.3, 0.9], frame_id=1) + out1 = bt.update(dets1) + assert out1.det_indices is not None + idx_of_track_1 = int(out1.det_indices[out1.track_ids.tolist().index(1)]) + assert idx_of_track_1 == 0 + + def test_empty_frame_has_empty_det_indices(self, fast_confirm_config): + bt = ByteTrackTracker(fast_confirm_config) + out = bt.update(_dets([], [], frame_id=0)) + assert out.det_indices is not None + assert len(out.det_indices) == 0 + + +class TestSubset: + def _dets_with_embeddings(self, dim: int = 128) -> Detections: + return Detections( + bboxes=np.zeros((3, 4), dtype=np.float32), + scores=np.ones(3, dtype=np.float32), + class_ids=np.zeros(3, dtype=np.int64), + frame_id=0, + embeddings=np.zeros((3, dim), dtype=np.float32), + ) + + def test_empty_subset_preserves_embedding_dim(self): + empty = _subset(self._dets_with_embeddings(dim=128), []) + assert len(empty) == 0 + assert empty.embeddings is not None + assert empty.embeddings.shape == (0, 128) + + def test_subset_keeps_selected_rows(self): + subset = _subset(self._dets_with_embeddings(), [0, 2]) + assert len(subset) == 2 + assert subset.embeddings is not None + assert subset.embeddings.shape == (2, 128) diff --git a/libraries/getitrack/tests/unit/test_config.py b/libraries/getitrack/tests/unit/test_config.py index 115a84fb85c..e05cd9df8ef 100644 --- a/libraries/getitrack/tests/unit/test_config.py +++ b/libraries/getitrack/tests/unit/test_config.py @@ -28,7 +28,7 @@ def test_lifecycle_defaults(self): assert (lc.min_hits, lc.tentative_max_age, lc.max_age) == (2, 0, 30) def test_motion_default(self): - assert MotionConfig().velocity_decay == pytest.approx(0.99) + assert MotionConfig().velocity_decay == pytest.approx(1.0) def test_interpolation_defaults(self): ic = InterpolationConfig() diff --git a/libraries/getitrack/tests/unit/test_kalman.py b/libraries/getitrack/tests/unit/test_kalman.py new file mode 100644 index 00000000000..80286dc4a6c --- /dev/null +++ b/libraries/getitrack/tests/unit/test_kalman.py @@ -0,0 +1,63 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the Kalman filter.""" + +import numpy as np +import pytest + +from getitrack.config import MotionConfig +from getitrack.motion import KalmanFilter + + +class TestKalmanInitiate: + def test_initial_mean_matches_measurement_position(self): + kf = KalmanFilter() + meas = np.array([100.0, 50.0, 0.5, 80.0]) + mean, cov = kf.initiate(meas) + assert mean.shape == (8,) + assert cov.shape == (8, 8) + np.testing.assert_allclose(mean[:4], meas) + np.testing.assert_allclose(mean[4:], 0.0) + + +class TestKalmanPredictUpdate: + def _kf_state(self) -> tuple[KalmanFilter, np.ndarray, np.ndarray]: + kf = KalmanFilter() + mean, cov = kf.initiate(np.array([100.0, 50.0, 0.5, 80.0])) + return kf, mean, cov + + def test_predict_and_update_move_state_toward_measurement(self): + kf, mean, cov = self._kf_state() + mean[4] = 5.0 # vx + pred_mean, pred_cov = kf.predict(mean, cov) + assert pred_mean[0] == pytest.approx(105.0, abs=1e-6) + + meas = np.array([110.0, 60.0, 0.5, 80.0]) + new_mean, new_cov = kf.update(pred_mean, pred_cov, meas) + # Posterior should sit between prior prediction and observation. + assert pred_mean[0] < new_mean[0] <= meas[0] + # Variance can only shrink after an observation. + assert new_cov[0, 0] < pred_cov[0, 0] + + def test_multi_predict_matches_predict_per_track(self): + kf, mean, cov = self._kf_state() + # Build two tracks with identical state. + means = np.stack([mean, mean], axis=0) + covs = np.stack([cov, cov], axis=0) + mp_means, mp_covs = kf.multi_predict(means, covs) + single_mean, single_cov = kf.predict(mean, cov) + np.testing.assert_allclose(mp_means[0], single_mean, atol=1e-8) + np.testing.assert_allclose(mp_means[1], single_mean, atol=1e-8) + np.testing.assert_allclose(mp_covs[0], single_cov, atol=1e-8) + + +class TestKalmanFromConfig: + def test_velocity_decay_below_one_dampens_velocity(self): + cfg = MotionConfig(velocity_decay=0.5) + kf = KalmanFilter.from_config(cfg) + mean = np.zeros(8) + mean[4] = 10.0 # vx + cov = np.eye(8) + new_mean, _ = kf.predict(mean, cov) + # vx should be halved after one step. + assert new_mean[4] == pytest.approx(5.0, abs=1e-6) diff --git a/libraries/getitrack/tests/unit/test_matching.py b/libraries/getitrack/tests/unit/test_matching.py new file mode 100644 index 00000000000..21908279db4 --- /dev/null +++ b/libraries/getitrack/tests/unit/test_matching.py @@ -0,0 +1,75 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Tests for IoU computation and linear assignment.""" + +import numpy as np +import pytest + +from getitrack.matching.iou import ( + fuse_score, + iou_distance, + iou_matrix, + linear_assignment, +) + + +class TestIoUMatrix: + def test_half_overlap_value(self): + a = np.array([[0, 0, 10, 10]], dtype=np.float32) + b = np.array([[5, 0, 15, 10]], dtype=np.float32) + # Intersection 50, union 150, IoU = 1/3. + assert iou_matrix(a, b)[0, 0] == pytest.approx(1.0 / 3.0, abs=1e-5) + + def test_iou_distance_is_float32_complement(self): + a = np.array([[0, 0, 10, 10]], dtype=np.float32) + b = np.array([[5, 0, 15, 10]], dtype=np.float32) + dist = iou_distance(a, b) + # numpy>=2.0 (NEP 50) keeps ``1.0 - float32`` as float32, no upcast. + assert dist.dtype == np.float32 + assert dist[0, 0] == pytest.approx(1.0 - 1.0 / 3.0, abs=1e-5) + + def test_empty_input_returns_empty_matrix(self): + empty = np.empty((0, 4), dtype=np.float32) + boxes = np.array([[0, 0, 10, 10]], dtype=np.float32) + assert iou_matrix(empty, boxes).shape == (0, 1) + assert iou_matrix(boxes, empty).shape == (1, 0) + + def test_bad_shape_raises(self): + bad = np.zeros((3, 5), dtype=np.float32) + good = np.zeros((1, 4), dtype=np.float32) + with pytest.raises(ValueError, match="boxes_a must have shape"): + iou_matrix(bad, good) + + def test_nan_input_raises(self): + nan_box = np.array([[0.0, 0.0, np.nan, 10.0]], dtype=np.float32) + good = np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32) + with pytest.raises(ValueError, match="boxes_a contains non-finite"): + iou_matrix(nan_box, good) + with pytest.raises(ValueError, match="boxes_b contains non-finite"): + iou_matrix(good, nan_box) + + +class TestFuseScore: + def test_high_score_lowers_cost(self): + cost = np.array([[0.5, 0.5]], dtype=np.float32) + scores = np.array([1.0, 0.1], dtype=np.float32) + fused = fuse_score(cost, scores) + assert fused[0, 0] < fused[0, 1] + + +class TestLinearAssignment: + def test_empty_cost_matrix(self): + cost = np.empty((0, 3), dtype=np.float32) + matches, ua, ub = linear_assignment(cost, thresh=0.5) + assert matches.shape == (0, 2) + assert ua.tolist() == [] + assert ub.tolist() == [0, 1, 2] + + def test_over_threshold_pairs_excluded_from_solution(self): + # Unconstrained Hungarian would pick (0->1, 1->0); gating the over-threshold + # (0,1) pair first yields the valid optimum (0->0), leaving row 1 unmatched. + cost = np.array([[0.1, 0.51], [0.5, 100.0]], dtype=np.float32) + matches, ua, ub = linear_assignment(cost, thresh=0.5) + assert matches.tolist() == [[0, 0]] + assert ua.tolist() == [1] + assert ub.tolist() == [1] diff --git a/libraries/getitrack/tests/unit/test_utils.py b/libraries/getitrack/tests/unit/test_utils.py new file mode 100644 index 00000000000..67648015cb4 --- /dev/null +++ b/libraries/getitrack/tests/unit/test_utils.py @@ -0,0 +1,29 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +"""Tests for getitrack.utils bbox-format conversions.""" + +import numpy as np +import pytest + +from getitrack.utils import xyah_to_xyxy, xyxy_to_xyah + + +class TestConversions: + def test_xyxy_xyah_roundtrip(self): + boxes = np.array( + [[10.0, 20.0, 30.0, 60.0], [0.0, 0.0, 100.0, 50.0]], + ) + xyah = xyxy_to_xyah(boxes) + back = xyah_to_xyxy(xyah) + np.testing.assert_allclose(back, boxes, atol=1e-6) + + @pytest.mark.parametrize( + "box", + [ + np.array([[0.0, 5.0, 10.0, 5.0]]), + np.array([[10.0, 0.0, 5.0, 10.0]]), + ], + ) + def test_invalid_geometry_raises(self, box): + with pytest.raises(ValueError, match="positive width and height"): + xyxy_to_xyah(box) diff --git a/libraries/getitrack/uv.lock b/libraries/getitrack/uv.lock index ab7a39d9804..b2503a9f20b 100644 --- a/libraries/getitrack/uv.lock +++ b/libraries/getitrack/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.15" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] [[package]] name = "annotated-types" @@ -28,6 +32,8 @@ dependencies = [ { name = "numpy" }, { name = "pydantic" }, { name = "pyyaml" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [package.dev-dependencies] @@ -43,10 +49,11 @@ lint = [ [package.metadata] requires-dist = [ - { name = "loguru", specifier = ">=0.7" }, - { name = "numpy", specifier = ">=2.0" }, - { name = "pydantic", specifier = ">=2.7" }, + { name = "loguru", specifier = "~=0.7" }, + { name = "numpy", specifier = "~=2.0" }, + { name = "pydantic", specifier = "~=2.7" }, { name = "pyyaml", specifier = "==6.0.3" }, + { name = "scipy", specifier = "~=1.13" }, ] [package.metadata.requires-dev] @@ -429,6 +436,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518"