diff --git a/markit/markitlib/config.py b/markit/markitlib/config.py index 2072843..5eb61b9 100644 --- a/markit/markitlib/config.py +++ b/markit/markitlib/config.py @@ -215,6 +215,7 @@ def __init__(self, args: argparse.Namespace): self.no_size_step_detection = getattr(args, "no_size_step_detection", False) self.no_frame_intervals = getattr(args, "no_frame_intervals", False) self.no_angle_normalization = getattr(args, "no_angle_normalization", False) + self.angle_spline_smoothing = getattr(args, "angle_spline_interpolation", None) # Drone info for streams block self.drone_info_path = getattr(args, "drone_info", None) diff --git a/markit/markitlib/postprocessing/__init__.py b/markit/markitlib/postprocessing/__init__.py index fc44a29..3d7d3d0 100644 --- a/markit/markitlib/postprocessing/__init__.py +++ b/markit/markitlib/postprocessing/__init__.py @@ -23,6 +23,7 @@ StaticObjectRemovalPass, ShortDurationPass, AngleNormalizationPass, + AngleSplineInterpolationPass, ) from .pipeline import PostprocessingPipeline @@ -43,5 +44,6 @@ "StaticObjectRemovalPass", "ShortDurationPass", "AngleNormalizationPass", + "AngleSplineInterpolationPass", "PostprocessingPipeline", ] diff --git a/markit/markitlib/postprocessing/_passes/__init__.py b/markit/markitlib/postprocessing/_passes/__init__.py index 91ab1cc..d389e55 100644 --- a/markit/markitlib/postprocessing/_passes/__init__.py +++ b/markit/markitlib/postprocessing/_passes/__init__.py @@ -13,6 +13,7 @@ from .rotation_90_jump_fix import Rotation90JumpFixPass from .rotation_temporal_smoothing import RotationTemporalSmoothingPass from .angle_normalization import AngleNormalizationPass +from .angle_spline_interpolation import AngleSplineInterpolationPass __all__ = [ "GapDetectionPass", @@ -30,4 +31,5 @@ "Rotation90JumpFixPass", "RotationTemporalSmoothingPass", "AngleNormalizationPass", + "AngleSplineInterpolationPass", ] diff --git a/markit/markitlib/postprocessing/_passes/angle_spline_interpolation.py b/markit/markitlib/postprocessing/_passes/angle_spline_interpolation.py new file mode 100644 index 0000000..63d534e --- /dev/null +++ b/markit/markitlib/postprocessing/_passes/angle_spline_interpolation.py @@ -0,0 +1,152 @@ +""" +AngleSplineInterpolationPass - Derive bounding box angles from spline-fitted trajectories. +""" + +import logging +from collections import defaultdict +from typing import Any, Dict, List, Tuple + +import numpy as np +from scipy.interpolate import splev, splprep + +from ..base import PostprocessingPass +from ._common import update_housekeeping_annotator + +logger = logging.getLogger(__name__) + + +class AngleSplineInterpolationPass(PostprocessingPass): + """Set bounding box angles parallel to a cubic spline fitted to the trajectory. + + For each tracked object the pass fits a parametric cubic spline through + the (x, y) centre coordinates and then orients bounding boxes so that + their rotation equals the tangent angle of the spline. + + Consecutive duplicate positions (vehicle stopped) are removed before + fitting; their angles are forward-filled from the last moving position. + """ + + def __init__(self, smoothing_factor: float = 0.0): + """Initialize the pass. + + Args: + smoothing_factor: The ``s`` parameter passed to + ``scipy.interpolate.splprep``. ``0`` interpolates exactly; + larger values produce smoother curves. + """ + self.smoothing_factor = smoothing_factor + + # Statistics + self.objects_processed = 0 + self.objects_skipped = 0 + self.angles_updated = 0 + + # ------------------------------------------------------------------ + # Public interface (PostprocessingPass) + # ------------------------------------------------------------------ + + def process(self, openlabel_data: Dict[str, Any]) -> Dict[str, Any]: + """Fit splines and update bounding box angles.""" + frames = openlabel_data.get("openlabel", {}).get("frames", {}) + + object_frame_map: Dict[str, List[int]] = defaultdict(list) + for frame_idx_str, frame_data in frames.items(): + frame_idx = int(frame_idx_str) + for obj_id in frame_data.get("objects", {}): + object_frame_map[obj_id].append(frame_idx) + + for obj_id, frame_list in object_frame_map.items(): + frame_list_sorted = sorted(frame_list) + self._process_object(frames, obj_id, frame_list_sorted) + + logger.info( + f"AngleSplineInterpolation: processed {self.objects_processed} objects, " + f"skipped {self.objects_skipped}, updated {self.angles_updated} angles" + ) + return openlabel_data + + def get_statistics(self) -> Dict[str, Any]: + """Return processing statistics.""" + return { + "objects_processed": self.objects_processed, + "objects_skipped": self.objects_skipped, + "angles_updated": self.angles_updated, + } + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _deduplicate_positions( + xs: List[float], + ys: List[float], + ) -> Tuple[List[float], List[float], List[int]]: + """Remove consecutive duplicate (x, y) positions. + + Returns the deduplicated x/y lists and a mapping from each original + index to the index of the unique point it corresponds to (for + forward-filling angles later). + """ + unique_xs: List[float] = [] + unique_ys: List[float] = [] + orig_to_unique: List[int] = [] + + for i, (x, y) in enumerate(zip(xs, ys)): + if not unique_xs or (x != unique_xs[-1] or y != unique_ys[-1]): + unique_xs.append(x) + unique_ys.append(y) + orig_to_unique.append(len(unique_xs) - 1) + + return unique_xs, unique_ys, orig_to_unique + + def _process_object( + self, + frames: Dict[str, Any], + obj_id: str, + frame_list: List[int], + ) -> None: + """Fit a spline and update angles for a single object.""" + # Collect centre coordinates in frame order + xs: List[float] = [] + ys: List[float] = [] + for frame_idx in frame_list: + rbbox = frames[str(frame_idx)]["objects"][obj_id][ + "object_data" + ]["rbbox"][0]["val"] + xs.append(rbbox[0]) + ys.append(rbbox[1]) + + unique_xs, unique_ys, orig_to_unique = self._deduplicate_positions(xs, ys) + + # Cubic spline needs at least k+1 = 4 unique points + if len(unique_xs) < 4: + self.objects_skipped += 1 + return + + self.objects_processed += 1 + + # Fit parametric cubic spline + tck, u = splprep( + [unique_xs, unique_ys], s=self.smoothing_factor, k=3 + ) + + # Evaluate first derivative → tangent vector + dx, dy = splev(u, tck, der=1) + unique_angles = np.arctan2(dy, dx).tolist() + + # Map angles back to every original frame (forward-fill for duplicates) + for i, frame_idx in enumerate(frame_list): + angle = unique_angles[orig_to_unique[i]] + frame_str = str(frame_idx) + obj_data = frames[frame_str]["objects"][obj_id] + rbbox = obj_data["object_data"]["rbbox"][0]["val"] + + rbbox[4] = angle + + # Normalise so width >= height (heading along long axis) + if rbbox[3] > rbbox[2]: + rbbox[2], rbbox[3] = rbbox[3], rbbox[2] + + update_housekeeping_annotator(obj_data, "spline") + self.angles_updated += 1 diff --git a/markit/markitlib/postprocessing/passes.py b/markit/markitlib/postprocessing/passes.py index 5f6108d..9e7bbeb 100644 --- a/markit/markitlib/postprocessing/passes.py +++ b/markit/markitlib/postprocessing/passes.py @@ -22,4 +22,5 @@ Rotation90JumpFixPass, RotationTemporalSmoothingPass, AngleNormalizationPass, + AngleSplineInterpolationPass, ) diff --git a/markit/run_markit.py b/markit/run_markit.py index ba90388..87cf91a 100755 --- a/markit/run_markit.py +++ b/markit/run_markit.py @@ -69,6 +69,7 @@ --no-size-step-detection Disable size step detection pass --no-frame-intervals Disable frame intervals pass --no-angle-normalization Disable angle normalization pass + --angle-spline-interpolation S Enable spline-based angle interpolation (S = splprep smoothing factor) VLM Scene Analysis: --vlm Enable VLM-based scene analysis for scenario tagging @@ -129,6 +130,7 @@ StaticObjectRemovalPass, ShortDurationPass, AngleNormalizationPass, + AngleSplineInterpolationPass, ) # Configure logging @@ -531,6 +533,11 @@ def parse_arguments() -> argparse.Namespace: "--no-angle-normalization", action="store_true", help="Disable angle normalization pass", ) + postproc.add_argument( + "--angle-spline-interpolation", type=float, default=None, metavar="S", + help="Enable spline-based angle interpolation. S is the smoothing factor " + "for scipy splprep (0 = exact interpolation, larger = smoother).", + ) # Logging and debug logging_group = parser.add_argument_group("Logging and Debug") @@ -613,6 +620,10 @@ def build_arguments_string(args: argparse.Namespace) -> str: ]: if getattr(args, flag, False): parts.append(f"--{flag.replace('_', '-')}") + # Record spline interpolation if enabled + spline_s = getattr(args, "angle_spline_interpolation", None) + if spline_s is not None: + parts.append(f"--angle-spline-interpolation {spline_s}") if args.output_video: parts.append(f"--output_video {args.output_video}") if args.aruco_csv: @@ -868,6 +879,14 @@ def main(): ) ) + # 8b. Spline-based angle interpolation (opt-in) + if config.angle_spline_smoothing is not None: + postprocessing_pipeline.add_pass( + AngleSplineInterpolationPass( + smoothing_factor=config.angle_spline_smoothing, + ) + ) + # 9. Duplicate removal - runs AFTER all rotation fixes so IoU is accurate if not config.no_duplicate_removal: postprocessing_pipeline.add_pass( diff --git a/markit/tests/test_angle_spline_interpolation.py b/markit/tests/test_angle_spline_interpolation.py new file mode 100644 index 0000000..60747e7 --- /dev/null +++ b/markit/tests/test_angle_spline_interpolation.py @@ -0,0 +1,420 @@ +""" +Tests for AngleSplineInterpolationPass. +""" + +import math + +import numpy as np +import pytest + +from markit.markitlib.postprocessing import AngleSplineInterpolationPass + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_openlabel(trajectories): + """Build a minimal OpenLabel structure from trajectory descriptions. + + Args: + trajectories: dict mapping obj_id → list of (frame_idx, x, y, w, h, r) + tuples sorted by frame_idx. + + Returns: + OpenLabel-style dict ready for pass.process(). + """ + frames = {} + objects = {} + for obj_id, points in trajectories.items(): + objects[obj_id] = {"name": obj_id, "type": "car"} + for frame_idx, x, y, w, h, r in points: + frame_str = str(frame_idx) + if frame_str not in frames: + frames[frame_str] = {"objects": {}} + frames[frame_str]["objects"][obj_id] = { + "object_data": { + "rbbox": [{"name": "shape", "val": [x, y, w, h, r]}], + "vec": [ + {"name": "annotator", "val": ["yolo_obb_v8"]}, + {"name": "confidence", "val": [0.9]}, + ], + } + } + return {"openlabel": {"frames": frames, "objects": objects}} + + +def _get_angles(openlabel_data, obj_id): + """Extract the angle sequence for an object, sorted by frame index.""" + frames = openlabel_data["openlabel"]["frames"] + result = [] + for frame_str in sorted(frames, key=lambda s: int(s)): + objs = frames[frame_str].get("objects", {}) + if obj_id in objs: + rbbox = objs[obj_id]["object_data"]["rbbox"][0]["val"] + result.append((int(frame_str), rbbox[4])) + return result + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAngleSplineInterpolationPass: + """Tests for the spline-based angle interpolation pass.""" + + def test_straight_line_horizontal(self): + """Object moving right along x-axis → all angles ≈ 0.""" + traj = { + "obj_1": [ + (i, 100.0 + i * 10.0, 200.0, 80, 40, 1.5) + for i in range(10) + ] + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + for frame_idx, angle in _get_angles(result, "obj_1"): + assert abs(angle) < 0.05, ( + f"Frame {frame_idx}: expected ≈0, got {angle:.4f}" + ) + + assert p.objects_processed == 1 + assert p.objects_skipped == 0 + assert p.angles_updated == 10 + + def test_straight_line_diagonal(self): + """Object moving at 45° → all angles ≈ π/4.""" + traj = { + "obj_1": [ + (i, 100.0 + i * 10.0, 100.0 + i * 10.0, 80, 40, 0.0) + for i in range(10) + ] + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + for frame_idx, angle in _get_angles(result, "obj_1"): + assert abs(angle - math.pi / 4) < 0.05, ( + f"Frame {frame_idx}: expected ≈π/4, got {angle:.4f}" + ) + + def test_curved_trajectory(self): + """Quarter-circle arc: angle should rotate from 0 to ~π/2.""" + n = 20 + t = np.linspace(0, math.pi / 2, n) + radius = 200.0 + xs = (radius * np.cos(t) + 300.0).tolist() + ys = (radius * np.sin(t) + 300.0).tolist() + + traj = { + "obj_1": [ + (i, xs[i], ys[i], 80, 40, 0.0) + for i in range(n) + ] + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + angles = _get_angles(result, "obj_1") + # First angle should be near π/2 (tangent to cos at t=0 is -sin → pointing up) + # Actually for parametric circle (cos t, sin t): tangent = (-sin t, cos t) + # At t=0: tangent is (0, 1) → angle = π/2 + # At t=π/2: tangent is (-1, 0) → angle = π (or -π, equivalent) + first_angle = angles[0][1] + last_angle = angles[-1][1] + + assert abs(first_angle - math.pi / 2) < 0.15, ( + f"First angle should be ≈π/2, got {first_angle:.4f}" + ) + # atan2 may return -π or π; both represent the same direction + assert abs(abs(last_angle) - math.pi) < 0.15, ( + f"Last angle should be ≈±π, got {last_angle:.4f}" + ) + + # Angles should be monotonically increasing (≈π/2 → ≈π) + # Use angular difference to handle the ±π wrap-around + for i in range(1, len(angles)): + diff = angles[i][1] - angles[i - 1][1] + # Normalize to [-π, π] + diff = (diff + math.pi) % (2 * math.pi) - math.pi + assert diff >= -0.1, ( + f"Angles should increase: frame {angles[i][0]} " + f"({angles[i][1]:.4f}) vs frame {angles[i-1][0]} " + f"({angles[i-1][1]:.4f}), diff={diff:.4f}" + ) + + def test_stationary_frames_forward_fill(self): + """Repeated positions should get forward-filled angles.""" + # Moving right, then stopped for 4 frames, then moving right again + traj_points = [] + # Moving phase 1: frames 0-4 + for i in range(5): + traj_points.append((i, 100.0 + i * 20.0, 200.0, 80, 40, 0.0)) + # Stationary phase: frames 5-8 (same position as frame 4) + for i in range(5, 9): + traj_points.append((i, 180.0, 200.0, 80, 40, 0.0)) + # Moving phase 2: frames 9-14 + for i in range(9, 15): + traj_points.append( + (i, 180.0 + (i - 8) * 20.0, 200.0, 80, 40, 0.0) + ) + + traj = {"obj_1": traj_points} + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + angles = _get_angles(result, "obj_1") + angle_dict = dict(angles) + + # All angles should be approximately 0 (moving right along x-axis) + for frame_idx, angle in angles: + assert abs(angle) < 0.15, ( + f"Frame {frame_idx}: expected ≈0 (rightward), got {angle:.4f}" + ) + + # Stationary frames should have the same angle as the last moving frame + # before the stop (forward-fill) + last_moving_angle = angle_dict[4] + for i in range(5, 9): + assert angle_dict[i] == pytest.approx(last_moving_angle, abs=1e-10), ( + f"Stationary frame {i}: expected forward-filled angle " + f"{last_moving_angle:.6f}, got {angle_dict[i]:.6f}" + ) + + def test_too_few_points_skipped(self): + """Object with <4 frames should be skipped.""" + traj = { + "obj_1": [ + (0, 100.0, 200.0, 80, 40, 1.0), + (1, 110.0, 200.0, 80, 40, 1.0), + (2, 120.0, 200.0, 80, 40, 1.0), + ] + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + # Angles should remain unchanged + for frame_idx, angle in _get_angles(result, "obj_1"): + assert angle == 1.0, f"Frame {frame_idx}: angle should be unchanged" + + assert p.objects_skipped == 1 + assert p.objects_processed == 0 + assert p.angles_updated == 0 + + def test_single_frame_skipped(self): + """Single-frame object should be skipped.""" + traj = {"obj_1": [(0, 100.0, 200.0, 80, 40, 0.5)]} + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + angles = _get_angles(result, "obj_1") + assert angles[0][1] == 0.5 + assert p.objects_skipped == 1 + + def test_all_positions_identical_skipped(self): + """Object where all positions are the same should be skipped.""" + traj = { + "obj_1": [ + (i, 100.0, 200.0, 80, 40, 0.0) + for i in range(10) + ] + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + p.process(data) + + # Only 1 unique point after dedup → skipped + assert p.objects_skipped == 1 + assert p.objects_processed == 0 + + def test_width_height_normalization(self): + """After angle update, width should be >= height.""" + # h > w initially — should be swapped + traj = { + "obj_1": [ + (i, 100.0 + i * 10.0, 200.0, 30, 80, 0.0) + for i in range(10) + ] + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + frames = result["openlabel"]["frames"] + for frame_str, frame_data in frames.items(): + rbbox = frame_data["objects"]["obj_1"]["object_data"]["rbbox"][0]["val"] + assert rbbox[2] >= rbbox[3], ( + f"Frame {frame_str}: width ({rbbox[2]}) should be >= " + f"height ({rbbox[3]})" + ) + + def test_housekeeping_tag_added(self): + """Processed frames should have 'spline' housekeeping tag.""" + traj = { + "obj_1": [ + (i, 100.0 + i * 10.0, 200.0, 80, 40, 0.0) + for i in range(10) + ] + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + frames = result["openlabel"]["frames"] + for frame_str, frame_data in frames.items(): + obj = frame_data["objects"]["obj_1"] + vec_list = obj["object_data"]["vec"] + annotator_vals = None + for vec_item in vec_list: + if vec_item.get("name") == "annotator": + annotator_vals = vec_item["val"] + break + assert annotator_vals is not None + assert any("spline" in v for v in annotator_vals), ( + f"Frame {frame_str}: missing 'spline' housekeeping tag" + ) + + def test_statistics(self): + """get_statistics() should return expected keys and values.""" + traj = { + "obj_1": [ + (i, 100.0 + i * 10.0, 200.0, 80, 40, 0.0) + for i in range(10) + ], + "obj_2": [ + (0, 50.0, 50.0, 40, 20, 0.0), + (1, 60.0, 50.0, 40, 20, 0.0), + ], + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + p.process(data) + stats = p.get_statistics() + + assert stats["objects_processed"] == 1 + assert stats["objects_skipped"] == 1 + assert stats["angles_updated"] == 10 + + def test_smoothing_factor_effect(self): + """Higher smoothing should produce less variation in angles on noisy data.""" + np.random.seed(42) + n = 30 + xs = np.linspace(100, 400, n) + ys = 200.0 + np.random.normal(0, 5, n) # noisy y + + traj = { + "obj_1": [ + (i, float(xs[i]), float(ys[i]), 80, 40, 0.0) + for i in range(n) + ] + } + + # Exact interpolation (s=0) + data_exact = _make_openlabel(traj) + p_exact = AngleSplineInterpolationPass(smoothing_factor=0.0) + result_exact = p_exact.process(data_exact) + angles_exact = [a for _, a in _get_angles(result_exact, "obj_1")] + + # Smooth interpolation (s=large) + data_smooth = _make_openlabel(traj) + p_smooth = AngleSplineInterpolationPass(smoothing_factor=100.0) + result_smooth = p_smooth.process(data_smooth) + angles_smooth = [a for _, a in _get_angles(result_smooth, "obj_1")] + + # Variance of angles should be smaller with more smoothing + var_exact = np.var(angles_exact) + var_smooth = np.var(angles_smooth) + assert var_smooth < var_exact, ( + f"Smooth variance ({var_smooth:.6f}) should be less than " + f"exact variance ({var_exact:.6f})" + ) + + def test_multiple_objects(self): + """Multiple objects should be processed independently.""" + traj = { + "obj_1": [ + (i, 100.0 + i * 10.0, 200.0, 80, 40, 0.5) + for i in range(10) + ], + "obj_2": [ + (i, 300.0, 100.0 + i * 10.0, 80, 40, 0.5) + for i in range(10) + ], + } + data = _make_openlabel(traj) + + p = AngleSplineInterpolationPass(smoothing_factor=0.0) + result = p.process(data) + + # obj_1 moves right → angle ≈ 0 + for _, angle in _get_angles(result, "obj_1"): + assert abs(angle) < 0.05 + + # obj_2 moves down → angle ≈ π/2 + for _, angle in _get_angles(result, "obj_2"): + assert abs(angle - math.pi / 2) < 0.05 + + assert p.objects_processed == 2 + + +class TestDeduplicatePositions: + """Unit tests for the static _deduplicate_positions helper.""" + + def test_no_duplicates(self): + xs = [1.0, 2.0, 3.0, 4.0] + ys = [10.0, 20.0, 30.0, 40.0] + ux, uy, mapping = AngleSplineInterpolationPass._deduplicate_positions( + xs, ys + ) + assert ux == xs + assert uy == ys + assert mapping == [0, 1, 2, 3] + + def test_consecutive_duplicates(self): + xs = [1.0, 1.0, 1.0, 2.0, 3.0, 3.0] + ys = [10.0, 10.0, 10.0, 20.0, 30.0, 30.0] + ux, uy, mapping = AngleSplineInterpolationPass._deduplicate_positions( + xs, ys + ) + assert ux == [1.0, 2.0, 3.0] + assert uy == [10.0, 20.0, 30.0] + assert mapping == [0, 0, 0, 1, 2, 2] + + def test_non_consecutive_duplicates_kept(self): + """Non-consecutive duplicates should not be merged.""" + xs = [1.0, 2.0, 1.0, 3.0] + ys = [10.0, 20.0, 10.0, 30.0] + ux, uy, mapping = AngleSplineInterpolationPass._deduplicate_positions( + xs, ys + ) + assert ux == [1.0, 2.0, 1.0, 3.0] + assert uy == [10.0, 20.0, 10.0, 30.0] + assert mapping == [0, 1, 2, 3] + + def test_all_same(self): + xs = [5.0, 5.0, 5.0] + ys = [5.0, 5.0, 5.0] + ux, uy, mapping = AngleSplineInterpolationPass._deduplicate_positions( + xs, ys + ) + assert ux == [5.0] + assert uy == [5.0] + assert mapping == [0, 0, 0] diff --git a/pyproject.toml b/pyproject.toml index 28e552b..86f888b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ markit = [ "lap>=0.5.12", "opencv-contrib-python>=4.5.0", "numpy", + "scipy>=1.10.0", "pyyaml", "pydantic>=2.0", "rdflib", diff --git a/uv.lock b/uv.lock index 38c8786..f234b4d 100644 --- a/uv.lock +++ b/uv.lock @@ -2149,6 +2149,8 @@ all-apps = [ { name = "pyqt6" }, { name = "pyyaml" }, { name = "rdflib" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "ultralytics" }, ] dev = [ @@ -2179,6 +2181,8 @@ markit = [ { name = "pyqt6" }, { name = "pyyaml" }, { name = "rdflib" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "ultralytics" }, ] trainit = [ @@ -2219,6 +2223,7 @@ all-apps = [ { name = "rdflib" }, { name = "rdflib", specifier = ">=7.0.0" }, { name = "rdflib", specifier = ">=7.2.1" }, + { name = "scipy", specifier = ">=1.10.0" }, { name = "ultralytics" }, ] dev = [ @@ -2247,6 +2252,7 @@ markit = [ { name = "pyqt6", specifier = ">=6.5.0" }, { name = "pyyaml" }, { name = "rdflib" }, + { name = "scipy", specifier = ">=1.10.0" }, { name = "ultralytics" }, ] trainit = [