From dd15b5db660f14d3638132a9b05de63b45b5a32d Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Thu, 26 Feb 2026 21:30:40 +0000 Subject: [PATCH 1/8] feat(plugin): add JSON sanitizer for NumPy types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add json_sanitize.py module - Convert np.float32/64 → Python float - Convert np.int32/64 → Python int - Convert np.ndarray → Python list - Recursive sanitization for nested structures - Comprehensive unit tests (23 tests) Phase 1 of v0.10.0 implementation. --- .../utils/json_sanitize.py | 66 ++++ .../tests/test_json_sanitize.py | 305 ++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/json_sanitize.py create mode 100644 plugins/forgesyte-yolo-tracker/tests/test_json_sanitize.py diff --git a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/json_sanitize.py b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/json_sanitize.py new file mode 100644 index 0000000..5244e68 --- /dev/null +++ b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/json_sanitize.py @@ -0,0 +1,66 @@ +"""JSON Sanitizer for NumPy types. + +v0.10.0: Ensures all plugin outputs are JSON-serializable. + +Converts NumPy types to Python primitives: +- np.float16, np.float32, np.float64 → float +- np.int8, np.int16, np.int32, np.int64 → int +- np.uint8, np.uint16, np.uint32, np.uint64 → int +- np.ndarray → list +- np.bool_ → bool +""" + +import numpy as np +from typing import Any + + +def sanitize_json(obj: Any) -> Any: + """ + Recursively convert NumPy types into JSON-safe Python types. + + Ensures no np.float32, np.int64, np.ndarray, or other non-serializable + objects appear in the final plugin output. + + Args: + obj: Any Python object, potentially containing NumPy types + + Returns: + Object with all NumPy types converted to Python primitives + + Example: + >>> sanitize_json({ + ... "track_id": np.int64(1), + ... "center": np.array([np.float32(100.5), np.float32(200.5)]) + ... }) + {'track_id': 1, 'center': [100.5, 200.5]} + """ + # Dict → sanitize values + if isinstance(obj, dict): + return {k: sanitize_json(v) for k, v in obj.items()} + + # List / tuple → sanitize each element + if isinstance(obj, (list, tuple)): + return [sanitize_json(v) for v in obj] + + # NumPy array → convert to list, then sanitize elements + if isinstance(obj, np.ndarray): + return sanitize_json(obj.tolist()) + + # NumPy floats → Python float + if isinstance(obj, (np.float16, np.float32, np.float64)): + return float(obj) + + # NumPy ints → Python int + if isinstance(obj, (np.int8, np.int16, np.int32, np.int64)): + return int(obj) + + # NumPy unsigned ints → Python int + if isinstance(obj, (np.uint8, np.uint16, np.uint32, np.uint64)): + return int(obj) + + # NumPy bool → Python bool + if isinstance(obj, np.bool_): + return bool(obj) + + # Pass through Python primitives and None + return obj diff --git a/plugins/forgesyte-yolo-tracker/tests/test_json_sanitize.py b/plugins/forgesyte-yolo-tracker/tests/test_json_sanitize.py new file mode 100644 index 0000000..2642fbc --- /dev/null +++ b/plugins/forgesyte-yolo-tracker/tests/test_json_sanitize.py @@ -0,0 +1,305 @@ +"""Tests for JSON sanitizer module. + +Phase 1 of v0.10.0 implementation. +Tests for converting NumPy types to JSON-safe Python types. +""" + +import numpy as np +import json + + +class TestSanitizeJson: + """Unit tests for JSON sanitizer.""" + + def test_sanitize_numpy_float32(self) -> None: + """Test np.float32 → Python float conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.float32(1.23) + result = sanitize_json(input_val) + + # Use approximate equality due to float32 precision + assert abs(result - 1.23) < 1e-6 + assert type(result) is float # noqa: E721 + assert not isinstance(result, np.floating) + + def test_sanitize_numpy_float64(self) -> None: + """Test np.float64 → Python float conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.float64(3.14159) + result = sanitize_json(input_val) + + assert result == 3.14159 + assert type(result) is float # noqa: E721 + + def test_sanitize_numpy_int32(self) -> None: + """Test np.int32 → Python int conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.int32(42) + result = sanitize_json(input_val) + + assert result == 42 + assert type(result) is int # noqa: E721 + assert not isinstance(result, np.integer) + + def test_sanitize_numpy_int64(self) -> None: + """Test np.int64 → Python int conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.int64(9999999999) + result = sanitize_json(input_val) + + assert result == 9999999999 + assert type(result) is int # noqa: E721 + + def test_sanitize_numpy_array(self) -> None: + """Test np.ndarray → Python list conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.array([1, 2, 3]) + result = sanitize_json(input_val) + + assert result == [1, 2, 3] + assert type(result) is list # noqa: E721 + + def test_sanitize_numpy_array_of_floats(self) -> None: + """Test np.ndarray of floats → list of Python floats.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.array([1.1, 2.2, 3.3], dtype=np.float32) + result = sanitize_json(input_val) + + # Use approximate equality due to float32 precision + assert len(result) == 3 + assert abs(result[0] - 1.1) < 1e-6 + assert abs(result[1] - 2.2) < 1e-6 + assert abs(result[2] - 3.3) < 1e-6 + assert all(type(x) is float for x in result) # noqa: E721 + + def test_sanitize_nested_dict(self) -> None: + """Test nested dict with NumPy values.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = { + "a": np.float32(1.0), + "b": np.int64(2), + "c": "string", + } + result = sanitize_json(input_val) + + assert result == {"a": 1.0, "b": 2, "c": "string"} + assert type(result["a"]) is float # noqa: E721 + assert type(result["b"]) is int # noqa: E721 + + def test_sanitize_nested_list(self) -> None: + """Test nested list with NumPy values.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = [np.float32(1.0), np.int64(2), "string"] + result = sanitize_json(input_val) + + assert result == [1.0, 2, "string"] + assert type(result[0]) is float # noqa: E721 + assert type(result[1]) is int # noqa: E721 + + def test_sanitize_deeply_nested(self) -> None: + """Test deeply nested structure matching plugin output.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = { + "frames": [ + { + "detections": { + "tracked_objects": [ + { + "track_id": np.int64(1), + "center": np.array([np.float32(100.5), np.float32(200.5)]), + "xyxy": np.array([50, 100, 150, 300]), + } + ] + } + } + ] + } + result = sanitize_json(input_val) + + # Verify structure + assert "frames" in result + assert len(result["frames"]) == 1 + obj = result["frames"][0]["detections"]["tracked_objects"][0] + + # Verify types + assert type(obj["track_id"]) is int # noqa: E721 + assert type(obj["center"]) is list # noqa: E721 + assert type(obj["center"][0]) is float # noqa: E721 + assert type(obj["xyxy"]) is list # noqa: E721 + + def test_sanitize_2d_array(self) -> None: + """Test 2D NumPy array conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.array([[1, 2], [3, 4]]) + result = sanitize_json(input_val) + + assert result == [[1, 2], [3, 4]] + assert type(result) is list # noqa: E721 + assert type(result[0]) is list # noqa: E721 + + def test_sanitize_preserves_python_primitives(self) -> None: + """Test that Python primitives pass through unchanged.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = { + "int": 42, + "float": 3.14, + "str": "hello", + "bool": True, + "none": None, + } + result = sanitize_json(input_val) + + assert result == input_val + + def test_sanitize_empty_structures(self) -> None: + """Test empty dict and list.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + assert sanitize_json({}) == {} + assert sanitize_json([]) == [] + + def test_json_serializable_after_sanitize(self) -> None: + """Test that sanitized output is JSON-serializable.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = { + "numpy_float": np.float32(1.23), + "numpy_int": np.int64(42), + "numpy_array": np.array([1, 2, 3]), + "nested": { + "values": np.array([np.float32(1.0), np.float32(2.0)]) + } + } + result = sanitize_json(input_val) + + # Should not raise + json_str = json.dumps(result) + + # Should be deserializable + loaded = json.loads(json_str) + # Use approximate equality for float32 precision + assert abs(loaded["numpy_float"] - 1.23) < 1e-6 + assert loaded["numpy_int"] == 42 + + def test_sanitize_tuple_becomes_list(self) -> None: + """Test that tuples are converted to lists.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = (np.int64(1), np.int64(2), np.int64(3)) + result = sanitize_json(input_val) + + assert result == [1, 2, 3] + assert type(result) is list # noqa: E721 + + def test_sanitize_xyxy_bounding_box(self) -> None: + """Test typical bounding box array (xyxy format).""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = { + "xyxy": np.array([50.5, 100.25, 150.75, 200.5], dtype=np.float32) + } + result = sanitize_json(input_val) + + assert result["xyxy"] == [50.5, 100.25, 150.75, 200.5] + assert all(type(x) is float for x in result["xyxy"]) # noqa: E721 + + def test_sanitize_center_coordinates(self) -> None: + """Test center coordinate calculation result.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + xyxy = np.array([100.0, 200.0, 300.0, 400.0]) + center = np.array([ + float((xyxy[0] + xyxy[2]) / 2), + float((xyxy[1] + xyxy[3]) / 2) + ], dtype=np.float32) + + result = sanitize_json({"center": center}) + + assert result["center"] == [200.0, 300.0] + assert type(result["center"][0]) is float # noqa: E721 + + +class TestSanizeJsonEdgeCases: + """Edge case tests for JSON sanitizer.""" + + def test_sanitize_numpy_bool(self) -> None: + """Test np.bool_ conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.bool_(True) + result = sanitize_json(input_val) + + assert result is True + assert type(result) is bool # noqa: E721 + + def test_sanitize_large_array(self) -> None: + """Test large array performance.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.random.rand(1000, 4).astype(np.float32) + result = sanitize_json(input_val) + + assert len(result) == 1000 + assert len(result[0]) == 4 + assert all(type(x) is float for row in result for x in row) # noqa: E721 + + def test_sanitize_numpy_uint8(self) -> None: + """Test np.uint8 → Python int conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.uint8(255) + result = sanitize_json(input_val) + + assert result == 255 + assert type(result) is int # noqa: E721 + + def test_sanitize_numpy_uint16(self) -> None: + """Test np.uint16 → Python int conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.uint16(65535) + result = sanitize_json(input_val) + + assert result == 65535 + assert type(result) is int # noqa: E721 + + def test_sanitize_numpy_int8(self) -> None: + """Test np.int8 → Python int conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.int8(-128) + result = sanitize_json(input_val) + + assert result == -128 + assert type(result) is int # noqa: E721 + + def test_sanitize_numpy_int16(self) -> None: + """Test np.int16 → Python int conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.int16(-32768) + result = sanitize_json(input_val) + + assert result == -32768 + assert type(result) is int # noqa: E721 + + def test_sanitize_numpy_float16(self) -> None: + """Test np.float16 → Python float conversion.""" + from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + input_val = np.float16(1.5) + result = sanitize_json(input_val) + + assert result == 1.5 + assert type(result) is float # noqa: E721 From 354e25c806a14fdf521bb3022bf933e98886eea5 Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Thu, 26 Feb 2026 21:34:00 +0000 Subject: [PATCH 2/8] fix(plugin): wire JSON sanitizer into video tool outputs - Wrap all video tool returns with sanitize_json() - Add import for json_sanitize module - Add output sanitization tests (9 tests) Phase 2 of v0.10.0 implementation. --- .../src/forgesyte_yolo_tracker/plugin.py | 11 +- .../tests/test_plugin_output.py | 234 ++++++++++++++++++ 2 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 plugins/forgesyte-yolo-tracker/tests/test_plugin_output.py diff --git a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py index 69e3f8b..a2899d7 100644 --- a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py +++ b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py @@ -58,6 +58,7 @@ def on_unload(self) -> None: # pragma: no cover # noqa: B027 from forgesyte_yolo_tracker.tracking import ByteTrackFactory, get_tracker_ids from forgesyte_yolo_tracker.inference.radar import radar_json_with_annotated_frame from forgesyte_yolo_tracker.configs import load_model_config +from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json logger = logging.getLogger(__name__) @@ -304,10 +305,11 @@ def _run_video_tool( logger.info(f"Completed: {frame_index} frames") - return { + # v0.10.0: Sanitize output for JSON serialization + return sanitize_json({ "total_frames": frame_index, "frames": frame_results, - } + }) def _tool_video_ball_detection( @@ -475,10 +477,11 @@ def _tool_video_player_tracking( frame_index += 1 - return { + # v0.10.0: Sanitize output for JSON serialization + return sanitize_json({ "total_frames": frame_index, "frames": frame_results, - } + }) class Plugin(BasePlugin): # type: ignore[misc] diff --git a/plugins/forgesyte-yolo-tracker/tests/test_plugin_output.py b/plugins/forgesyte-yolo-tracker/tests/test_plugin_output.py new file mode 100644 index 0000000..c6758c8 --- /dev/null +++ b/plugins/forgesyte-yolo-tracker/tests/test_plugin_output.py @@ -0,0 +1,234 @@ +"""Tests for sanitized plugin output. + +Phase 2 of v0.10.0 implementation. +Tests to verify plugin output is JSON-serializable after sanitization. +""" + +import json +import numpy as np + +from forgesyte_yolo_tracker.utils.json_sanitize import sanitize_json + + +class TestPluginOutputSanitization: + """Tests for sanitized plugin output.""" + + def test_no_numpy_types_in_output(self) -> None: + """Test that no NumPy types remain in output.""" + # Simulated plugin output with NumPy types + output = { + "total_frames": np.int64(100), + "frames": [ + { + "frame_index": np.int64(0), + "detections": { + "tracked_objects": [ + { + "track_id": np.int64(1), + "class_id": np.int64(0), + "xyxy": np.array([100, 200, 300, 400], dtype=np.float32), + "center": np.array([200.0, 300.0], dtype=np.float32), + } + ] + } + } + ] + } + + result = sanitize_json(output) + + # Check all values are Python primitives + assert type(result["total_frames"]) is int # noqa: E721 + assert type(result["frames"][0]["frame_index"]) is int # noqa: E721 + obj = result["frames"][0]["detections"]["tracked_objects"][0] + assert type(obj["track_id"]) is int # noqa: E721 + assert type(obj["xyxy"]) is list # noqa: E721 + assert type(obj["center"]) is list # noqa: E721 + + def test_output_is_json_serializable(self) -> None: + """Test that output can be JSON serialized.""" + output = { + "total_frames": np.int64(100), + "frames": [ + { + "frame_index": np.int64(0), + "detections": { + "tracked_objects": [ + { + "track_id": np.int64(1), + "xyxy": np.array([100, 200, 300, 400], dtype=np.float32), + "center": np.array([200.0, 300.0], dtype=np.float32), + } + ] + } + } + ] + } + + result = sanitize_json(output) + + # Should not raise + json_str = json.dumps(result) + assert "total_frames" in json_str + + def test_video_tool_output_structure(self) -> None: + """Test video tool output structure matches expected format.""" + # Simulate video_player_tracking output + output = { + "total_frames": np.int64(10), + "frames": [ + { + "frame_index": np.int64(i), + "detections": { + "tracked_objects": [ + { + "track_id": np.int64(j), + "class_id": np.int64(0), + "xyxy": np.array([j * 10, j * 20, j * 30, j * 40], dtype=np.float32), + "center": np.array([j * 20.0, j * 30.0], dtype=np.float32), + } + for j in range(3) + ] + } + } + for i in range(10) + ] + } + + result = sanitize_json(output) + + # Verify structure is preserved + assert result["total_frames"] == 10 + assert len(result["frames"]) == 10 + assert len(result["frames"][0]["detections"]["tracked_objects"]) == 3 + + # Verify all types are JSON-safe + json.dumps(result) # Should not raise + + +class TestFloat32Fix: + """Tests for float32 center coordinate fix.""" + + def test_center_coordinates_are_python_floats(self) -> None: + """Test that center coordinates are Python floats, not np.float32.""" + xyxy = np.array([100.5, 200.25, 300.75, 400.5], dtype=np.float32) + + # Calculate center (as done in plugin) + center = [ + float((xyxy[0] + xyxy[2]) / 2), + float((xyxy[1] + xyxy[3]) / 2) + ] + + result = sanitize_json({"center": center}) + + assert type(result["center"][0]) is float # noqa: E721 + assert type(result["center"][1]) is float # noqa: E721 + # Should be JSON serializable + json.dumps(result) # Should not raise + + +class TestTrackIdFix: + """Tests for track_id truth-value fix.""" + + def test_track_id_handles_none(self) -> None: + """Test track_id handles None from get_tracker_ids.""" + from typing import Any + tracker_ids: Any = None + + # After fix: should handle gracefully + if tracker_ids is None: + tracker_ids = [] + + assert tracker_ids == [] + + def test_track_id_is_python_int(self) -> None: + """Test track_id is Python int, not np.int64.""" + track_id = np.int64(42) + result = sanitize_json({"track_id": track_id}) + + assert result["track_id"] == 42 + assert type(result["track_id"]) is int # noqa: E721 + + +class TestVideoBallDetectionOutput: + """Tests for video_ball_detection output sanitization.""" + + def test_ball_detection_output_json_serializable(self) -> None: + """Test ball detection output is JSON-serializable.""" + # Simulated output + output = { + "total_frames": np.int64(5), + "frames": [ + { + "frame_index": np.int64(i), + "detections": { + "xyxy": np.array([[100, 200, 150, 250]], dtype=np.float32).tolist(), + "confidence": np.array([0.95], dtype=np.float32).tolist(), + "class_id": np.array([0], dtype=np.int32).tolist(), + } + } + for i in range(5) + ] + } + + result = sanitize_json(output) + + # Should be JSON serializable + json_str = json.dumps(result) + assert "total_frames" in json_str + + +class TestVideoPitchDetectionOutput: + """Tests for video_pitch_detection output sanitization.""" + + def test_pitch_detection_output_json_serializable(self) -> None: + """Test pitch detection output is JSON-serializable.""" + # Simulated output with keypoints + output = { + "total_frames": np.int64(3), + "frames": [ + { + "frame_index": np.int64(i), + "detections": { + "keypoints_xy": np.random.rand(32, 2).astype(np.float32).tolist(), + "keypoints_conf": np.random.rand(32).astype(np.float32).tolist(), + } + } + for i in range(3) + ] + } + + result = sanitize_json(output) + + # Should be JSON serializable + json_str = json.dumps(result) + assert "total_frames" in json_str + + +class TestVideoRadarOutput: + """Tests for video_radar output sanitization.""" + + def test_radar_output_json_serializable(self) -> None: + """Test radar output is JSON-serializable.""" + # Simulated output + output = { + "total_frames": np.int64(3), + "frames": [ + { + "frame_index": np.int64(i), + "detections": { + "xyxy": np.random.rand(5, 4).astype(np.float32).tolist(), + "centers": np.random.rand(5, 2).astype(np.float32).tolist(), + "confidence": np.random.rand(5).astype(np.float32).tolist(), + "class_id": np.zeros(5, dtype=np.int32).tolist(), + } + } + for i in range(3) + ] + } + + result = sanitize_json(output) + + # Should be JSON serializable + json_str = json.dumps(result) + assert "total_frames" in json_str From 96d68e8bddb636ff5c04596cc63d635d4956aae6 Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Thu, 26 Feb 2026 21:37:55 +0000 Subject: [PATCH 3/8] feat(plugin): add streaming_video_analysis capability - Add streaming_video_analysis to capabilities - Update version to 0.10.0 - Add streaming capability to all video tools - Add manifest tests (10 tests) Phase 3 of v0.10.0 implementation. --- .../src/forgesyte_yolo_tracker/manifest.json | 17 ++-- .../tests/test_manifest.py | 83 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 plugins/forgesyte-yolo-tracker/tests/test_manifest.py diff --git a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/manifest.json b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/manifest.json index badf495..fc1b54a 100644 --- a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/manifest.json +++ b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/manifest.json @@ -1,8 +1,8 @@ { "id": "yolo-tracker", "name": "YOLO Tracker", - "version": "1.0.0", - "description": "ForgeSyte plugin for football player, ball, pitch, and radar analysis using YOLO + Supervision.", + "version": "0.10.0", + "description": "YOLO + Supervision plugin with streaming video analysis.", "author": "Roger", "license": "MIT", "entrypoint": "forgesyte_yolo_tracker.plugin", @@ -11,7 +11,8 @@ "player_detection", "ball_detection", "pitch_detection", - "radar" + "radar", + "streaming_video_analysis" ], "tools": [ { @@ -88,7 +89,7 @@ "description": "Run ball detection on video frames.", "input_types": ["video"], "output_types": ["frames"], - "capabilities": ["ball_detection"], + "capabilities": ["ball_detection", "streaming_video_analysis"], "inputs": { "video_path": "string", "device": "string" @@ -104,7 +105,7 @@ "description": "Run pitch detection on video frames.", "input_types": ["video"], "output_types": ["frames"], - "capabilities": ["pitch_detection"], + "capabilities": ["pitch_detection", "streaming_video_analysis"], "inputs": { "video_path": "string", "device": "string" @@ -120,7 +121,7 @@ "description": "Generate radar view from video frames.", "input_types": ["video"], "output_types": ["frames"], - "capabilities": ["radar"], + "capabilities": ["radar", "streaming_video_analysis"], "inputs": { "video_path": "string", "device": "string" @@ -136,7 +137,7 @@ "description": "Track players across video frames.", "input_types": ["video"], "output_types": ["frames"], - "capabilities": ["player_detection"], + "capabilities": ["player_detection", "streaming_video_analysis"], "inputs": { "video_path": "string", "device": "string" @@ -147,4 +148,4 @@ } } ] -} +} \ No newline at end of file diff --git a/plugins/forgesyte-yolo-tracker/tests/test_manifest.py b/plugins/forgesyte-yolo-tracker/tests/test_manifest.py new file mode 100644 index 0000000..05c36d5 --- /dev/null +++ b/plugins/forgesyte-yolo-tracker/tests/test_manifest.py @@ -0,0 +1,83 @@ +"""Tests for plugin manifest. + +Phase 3 of v0.10.0 implementation. +Tests for manifest structure and capabilities. +""" + +import json +from pathlib import Path +from typing import Any + +import pytest + + +class TestManifest: + """Tests for plugin manifest.""" + + @pytest.fixture # type: ignore[misc] + def manifest(self) -> dict[str, Any]: + manifest_path = Path(__file__).parent.parent / "src" / "forgesyte_yolo_tracker" / "manifest.json" + with open(manifest_path) as f: + data: dict[str, Any] = json.load(f) + return data + + def test_manifest_has_streaming_capability(self, manifest: dict[str, Any]) -> None: + """Test that manifest includes streaming_video_analysis capability.""" + assert "streaming_video_analysis" in manifest["capabilities"] + + def test_manifest_version_is_0_10_0(self, manifest: dict[str, Any]) -> None: + """Test that version is 0.10.0.""" + assert manifest["version"] == "0.10.0" + + def test_manifest_has_required_fields(self, manifest: dict[str, Any]) -> None: + """Test that manifest has all required fields.""" + required_fields = ["id", "name", "version", "description", "capabilities", "tools"] + for field in required_fields: + assert field in manifest, f"Missing required field: {field}" + + def test_video_tools_have_streaming_capability(self, manifest: dict[str, Any]) -> None: + """Test that video tools include streaming_video_analysis capability.""" + video_tools = [t for t in manifest["tools"] if "video" in t.get("id", "")] + + for tool in video_tools: + tool_caps = tool.get("capabilities", []) + assert "streaming_video_analysis" in tool_caps, \ + f"Tool {tool['id']} missing streaming_video_analysis capability" + + def test_all_capabilities_have_tools(self, manifest: dict[str, Any]) -> None: + """Test that each capability has at least one tool.""" + capabilities = set(manifest["capabilities"]) + tool_capabilities = set() + + for tool in manifest["tools"]: + tool_capabilities.update(tool.get("capabilities", [])) + + # All declared capabilities should be used by at least one tool + for cap in capabilities: + assert cap in tool_capabilities, f"Capability {cap} not used by any tool" + + def test_video_player_tracking_tool_exists(self, manifest: dict[str, Any]) -> None: + """Test that video_player_tracking tool exists.""" + tool_ids = [t["id"] for t in manifest["tools"]] + assert "video_player_tracking" in tool_ids + + def test_video_ball_detection_tool_exists(self, manifest: dict[str, Any]) -> None: + """Test that video_ball_detection tool exists.""" + tool_ids = [t["id"] for t in manifest["tools"]] + assert "video_ball_detection" in tool_ids + + def test_video_pitch_detection_tool_exists(self, manifest: dict[str, Any]) -> None: + """Test that video_pitch_detection tool exists.""" + tool_ids = [t["id"] for t in manifest["tools"]] + assert "video_pitch_detection" in tool_ids + + def test_video_radar_tool_exists(self, manifest: dict[str, Any]) -> None: + """Test that video_radar tool exists.""" + tool_ids = [t["id"] for t in manifest["tools"]] + assert "video_radar" in tool_ids + + def test_manifest_json_valid(self, manifest: dict[str, Any]) -> None: + """Test that manifest.json is valid JSON.""" + assert isinstance(manifest, dict) + assert "tools" in manifest + assert isinstance(manifest["tools"], list) \ No newline at end of file From 1d10fce5dc38341630f58a5cc239fd9bc7835a1d Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Thu, 26 Feb 2026 21:44:03 +0000 Subject: [PATCH 4/8] fix(tests): update ByteTrack tests to match try-except fallback strategy - Remove signature inspection tests (outdated) - Add test for NEW API success path - Add test for fallback to OLD API when NEW fails - Add test for RuntimeError when both APIs fail Tests now match the actual implementation in tracking.py. --- .../tests/unit/test_tracking.py | 142 ++++++++++-------- 1 file changed, 77 insertions(+), 65 deletions(-) diff --git a/plugins/forgesyte-yolo-tracker/tests/unit/test_tracking.py b/plugins/forgesyte-yolo-tracker/tests/unit/test_tracking.py index abbf05c..efbddb5 100644 --- a/plugins/forgesyte-yolo-tracker/tests/unit/test_tracking.py +++ b/plugins/forgesyte-yolo-tracker/tests/unit/test_tracking.py @@ -1,9 +1,9 @@ """Unit tests for ByteTrackFactory - API adaptive ByteTrack construction. -Tests that ByteTrackFactory correctly handles both old and new supervision APIs. +Tests that ByteTrackFactory correctly handles both old and new supervision APIs +using try-except fallback strategy. """ -import inspect from unittest.mock import MagicMock, patch import pytest @@ -15,109 +15,121 @@ class TestByteTrackFactory: def test_get_returns_bytetrack_instance(self) -> None: """Verify get() returns a ByteTrack instance.""" from forgesyte_yolo_tracker.tracking import ByteTrackFactory - + # Reset singleton for clean test ByteTrackFactory.reset() - + tracker = ByteTrackFactory.get() - + assert tracker is not None ByteTrackFactory.reset() def test_get_returns_singleton(self) -> None: """Verify get() returns the same instance on multiple calls.""" from forgesyte_yolo_tracker.tracking import ByteTrackFactory - + ByteTrackFactory.reset() - + tracker1 = ByteTrackFactory.get() tracker2 = ByteTrackFactory.get() - + assert tracker1 is tracker2 ByteTrackFactory.reset() def test_reset_clears_instance(self) -> None: """Verify reset() clears the singleton.""" from forgesyte_yolo_tracker.tracking import ByteTrackFactory - + tracker1 = ByteTrackFactory.get() ByteTrackFactory.reset() tracker2 = ByteTrackFactory.get() - + assert tracker1 is not tracker2 ByteTrackFactory.reset() - def test_build_with_old_api(self) -> None: - """Verify _build() uses old API when track_thresh exists.""" + def test_build_with_new_api(self) -> None: + """Verify _build() uses NEW API when it succeeds (try-except strategy).""" from forgesyte_yolo_tracker import tracking - + mock_bytetrack = MagicMock() mock_instance = MagicMock() mock_bytetrack.return_value = mock_instance - - # Create a mock signature with old API params - mock_sig = MagicMock() - mock_sig.parameters = {'track_thresh': None, 'track_buffer': None, 'match_thresh': None, 'frame_rate': None} - + with patch.object(tracking, 'sv') as mock_sv: mock_sv.ByteTrack = mock_bytetrack - with patch.object(inspect, 'signature', return_value=mock_sig): - tracking.ByteTrackFactory.reset() - tracking.ByteTrackFactory._build() - - # Should call with old API params - mock_bytetrack.assert_called_once_with( - track_thresh=0.25, - track_buffer=30, - match_thresh=0.8, - frame_rate=30, - ) - + tracking.ByteTrackFactory.reset() + tracking.ByteTrackFactory._build() + + # Should try NEW API first and succeed + mock_bytetrack.assert_called_once_with( + track_activation_threshold=0.25, + lost_track_buffer=30, + minimum_matching_threshold=0.8, + frame_rate=30, + ) + tracking.ByteTrackFactory.reset() - def test_build_with_new_api(self) -> None: - """Verify _build() uses new API when track_activation_threshold exists.""" + def test_build_falls_back_to_old_api(self) -> None: + """Verify _build() falls back to OLD API when NEW API fails.""" from forgesyte_yolo_tracker import tracking - + mock_bytetrack = MagicMock() mock_instance = MagicMock() - mock_bytetrack.return_value = mock_instance - - # Create a mock signature with new API params (no track_thresh) - mock_sig = MagicMock() - mock_sig.parameters = {'track_activation_threshold': None, 'lost_track_buffer': None, 'minimum_matching_threshold': None, 'frame_rate': None} - + + # First call (NEW API) fails, second call (OLD API) succeeds + mock_bytetrack.side_effect = [ + TypeError("NEW API not available"), + mock_instance, + ] + with patch.object(tracking, 'sv') as mock_sv: mock_sv.ByteTrack = mock_bytetrack - with patch.object(inspect, 'signature', return_value=mock_sig): - tracking.ByteTrackFactory.reset() - tracking.ByteTrackFactory._build() - - # Should call with new API params - mock_bytetrack.assert_called_once_with( - track_activation_threshold=0.25, - lost_track_buffer=30, - minimum_matching_threshold=0.8, - frame_rate=30, - ) - + tracking.ByteTrackFactory.reset() + result = tracking.ByteTrackFactory._build() + + # Should return the instance from OLD API fallback + assert result is mock_instance + # Should have been called twice: NEW API (failed) + OLD API (succeeded) + assert mock_bytetrack.call_count == 2 + + # First call: NEW API + first_call = mock_bytetrack.call_args_list[0] + assert first_call.kwargs == { + 'track_activation_threshold': 0.25, + 'lost_track_buffer': 30, + 'minimum_matching_threshold': 0.8, + 'frame_rate': 30, + } + + # Second call: OLD API + second_call = mock_bytetrack.call_args_list[1] + assert second_call.kwargs == { + 'track_thresh': 0.25, + 'track_buffer': 30, + 'match_thresh': 0.8, + 'frame_rate': 30, + } + tracking.ByteTrackFactory.reset() - def test_build_raises_on_unsupported_signature(self) -> None: - """Verify RuntimeError when neither API is recognized.""" + def test_build_raises_on_both_apis_failing(self) -> None: + """Verify RuntimeError when both NEW and OLD APIs fail.""" from forgesyte_yolo_tracker import tracking - + mock_bytetrack = MagicMock() - - # Create a mock signature with unknown params - mock_sig = MagicMock() - mock_sig.parameters = {'unknown_param': None} - + + # Both calls fail + mock_bytetrack.side_effect = [ + TypeError("NEW API not available"), + TypeError("OLD API not available"), + ] + with patch.object(tracking, 'sv') as mock_sv: mock_sv.ByteTrack = mock_bytetrack - with patch.object(inspect, 'signature', return_value=mock_sig): - tracking.ByteTrackFactory.reset() - with pytest.raises(RuntimeError, match="Unsupported ByteTrack"): - tracking.ByteTrackFactory._build() - - tracking.ByteTrackFactory.reset() + tracking.ByteTrackFactory.reset() + + with pytest.raises(RuntimeError, match="ByteTrack construction failed"): + tracking.ByteTrackFactory._build() + + tracking.ByteTrackFactory.reset() \ No newline at end of file From 7902f2f6d3c1fc4db1067b82b8ca0879c2564e5d Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Sun, 1 Mar 2026 12:17:29 +0000 Subject: [PATCH 5/8] fix(yolo-tracker): Wrap tool returns in success envelope Add success/result envelope to all tool returns for v0.10.1 compatibility. Changes: - Frame tools return: {"success": True, "result": data} - Video tools return: {"success": True, "result": {"total_frames", "frames"}} - Error paths return: {"success": False, "error": message} Fixes streaming JSON parsing errors and batch video job failures. Restores v0.9.7 behavior with proper JSON envelope. Amp-Thread-ID: https://ampcode.com/threads/T-019ca950-82ea-774e-b222-0e4123b8ca09 Co-authored-by: Amp --- .../src/forgesyte_yolo_tracker/plugin.py | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py index a2899d7..c95d135 100644 --- a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py +++ b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py @@ -122,10 +122,10 @@ def _tool_player_detection( if error: return error if annotated and frame is not None: - return detect_players_json_with_annotated_frame(frame, device=device) + return {"success": True, "result": detect_players_json_with_annotated_frame(frame, device=device)} if frame is not None: - return detect_players_json(frame, device=device) - return {"error": "image_decode_failed"} + return {"success": True, "result": detect_players_json(frame, device=device)} + return {"success": False, "error": "image_decode_failed"} @@ -137,10 +137,10 @@ def _tool_player_tracking( if error: return error if annotated and frame is not None: - return track_players_json_with_annotated_frame(frame, device=device) + return {"success": True, "result": track_players_json_with_annotated_frame(frame, device=device)} if frame is not None: - return track_players_json(frame, device=device) - return {"error": "image_decode_failed"} + return {"success": True, "result": track_players_json(frame, device=device)} + return {"success": False, "error": "image_decode_failed"} def _tool_video_player_tracking( @@ -221,10 +221,10 @@ def _tool_ball_detection( if error: return error if annotated and frame is not None: - return detect_ball_json_with_annotated_frame(frame, device=device) + return {"success": True, "result": detect_ball_json_with_annotated_frame(frame, device=device)} if frame is not None: - return detect_ball_json(frame, device=device) - return {"error": "image_decode_failed"} + return {"success": True, "result": detect_ball_json(frame, device=device)} + return {"success": False, "error": "image_decode_failed"} def _tool_pitch_detection( @@ -234,10 +234,10 @@ def _tool_pitch_detection( if error: return error if annotated and frame is not None: - return detect_pitch_json_with_annotated_frame(frame, device=device) + return {"success": True, "result": detect_pitch_json_with_annotated_frame(frame, device=device)} if frame is not None: - return detect_pitch_json(frame, device=device) - return {"error": "image_decode_failed"} + return {"success": True, "result": detect_pitch_json(frame, device=device)} + return {"success": False, "error": "image_decode_failed"} def _tool_radar( @@ -247,10 +247,10 @@ def _tool_radar( if error: return error if annotated and frame is not None: - return radar_json_with_annotated_frame(frame, device=device) + return {"success": True, "result": radar_json_with_annotated_frame(frame, device=device)} if frame is not None: - return radar_json(frame, device=device) - return {"error": "image_decode_failed"} + return {"success": True, "result": radar_json(frame, device=device)} + return {"success": False, "error": "image_decode_failed"} # --------------------------------------------------------- @@ -306,10 +306,13 @@ def _run_video_tool( logger.info(f"Completed: {frame_index} frames") # v0.10.0: Sanitize output for JSON serialization - return sanitize_json({ - "total_frames": frame_index, - "frames": frame_results, - }) + return { + "success": True, + "result": sanitize_json({ + "total_frames": frame_index, + "frames": frame_results, + }) + } def _tool_video_ball_detection( @@ -336,13 +339,13 @@ def handle_frame(result): } return {"xyxy": [], "confidence": [], "class_id": []} - return _run_video_tool( + return {"success": True, "result": _run_video_tool( model=model, video_path=video_path, progress_callback=progress_callback, frame_handler=handle_frame, device=device, - ) + )} def _tool_video_pitch_detection( @@ -370,13 +373,13 @@ def handle_frame(result): } return {"keypoints_xy": [], "keypoints_conf": []} - return _run_video_tool( + return {"success": True, "result": _run_video_tool( model=model, video_path=video_path, progress_callback=progress_callback, frame_handler=handle_frame, device=device, - ) + )} def _tool_video_radar( @@ -406,13 +409,13 @@ def handle_frame(result): } return {"xyxy": [], "centers": [], "confidence": [], "class_id": []} - return _run_video_tool( + return {"success": True, "result": _run_video_tool( model=model, video_path=video_path, progress_callback=progress_callback, frame_handler=handle_frame, device=device, - ) + )} def _tool_video_player_tracking( From 55a263b9cb6378386404b0e84237744b48b44890 Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Tue, 3 Mar 2026 22:59:10 +0000 Subject: [PATCH 6/8] chore: add project table to root pyproject.toml for plugin validation Amp-Thread-ID: https://ampcode.com/threads/T-019cb5d7-0737-7619-91f7-381f01705cd6 Co-authored-by: Amp --- .gitignore | 5 +++++ .../forgesyte_yolo_tracker/configs/models.yaml | 10 +++++----- pyproject.toml | 15 +++++++++++++++ uv.lock | 5 +++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index a3fe656..fa7e034 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,8 @@ logs/ # Scratch scratch/ + +#Yolo-Trcker Models +train_ball_detector.pt +train_pitch_keypoint_detector.pt +train_player_detector.pt \ No newline at end of file diff --git a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/configs/models.yaml b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/configs/models.yaml index 389e501..9ffc0bb 100644 --- a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/configs/models.yaml +++ b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/configs/models.yaml @@ -3,9 +3,9 @@ # Model file names (relative to the models/ directory) models: - player_detection: "football-player-detection-v3.pt" - ball_detection: "football-ball-detection-v2.pt" - pitch_detection: "football-pitch-detection-v1.pt" + player_detection: "train_player_detector.pt" + ball_detection: "train_ball_detector.pt" + pitch_detection: "train_pitch_keypoint_detector.pt" # Confidence thresholds for detection confidence: @@ -14,11 +14,11 @@ confidence: pitch: 0.25 # Default device for inference -device: "cuda" +device: "cpu" # Default detections to run in analyze() # Enable/disable each detection type. Set to true/false to control which run. default_detections: players: true ball: true - pitch: true + pitch: true \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2466f0b..f456ed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,18 @@ +[build-system] +requires = ["setuptools>=65", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "forgesyte-plugins" +version = "0.1.0" +description = "Plugin collection for ForgeSyte AI-vision platform" +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "ForgeSyte Contributors", email = "info@forgesyte.dev"} +] +requires-python = ">=3.9" + [tool.black] line-length = 88 target-version = ["py310", "py311", "py312"] diff --git a/uv.lock b/uv.lock index c0ba091..eb967b8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,3 +1,8 @@ version = 1 revision = 3 requires-python = ">=3.9" + +[[package]] +name = "forgesyte-plugins" +version = "0.1.0" +source = { editable = "." } From ae5f962198032f453cc3a315dbfb0473bfb7c719 Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Tue, 3 Mar 2026 23:14:24 +0000 Subject: [PATCH 7/8] fix: lazy load heavy dependencies in utils/__init__.py to avoid torch import errors Defer importing team.py and transformers until actually needed, preventing torch.__spec__ errors during test collection on CPU environments. Amp-Thread-ID: https://ampcode.com/threads/T-019cb5d7-0737-7619-91f7-381f01705cd6 Co-authored-by: Amp --- .../src/forgesyte_yolo_tracker/utils/__init__.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/__init__.py b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/__init__.py index ae66c66..7f00067 100644 --- a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/__init__.py +++ b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/utils/__init__.py @@ -13,8 +13,20 @@ """ from . import ball, soccer_pitch -from .team import TeamClassifier, create_batches -from .view import ViewTransformer + +# Lazy import to avoid torch/transformers at module load time +def __getattr__(name: str): + """Lazy load heavy dependencies to avoid import errors in tests.""" + if name == "TeamClassifier": + from .team import TeamClassifier + return TeamClassifier + elif name == "create_batches": + from .team import create_batches + return create_batches + elif name == "ViewTransformer": + from .view import ViewTransformer + return ViewTransformer + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") __all__ = [ # From local implementations From 9049c971a3c21e4d452740afcf63504fdb688733 Mon Sep 17 00:00:00 2001 From: Roger Taylor Date: Tue, 3 Mar 2026 23:25:01 +0000 Subject: [PATCH 8/8] fix: remove double wrapping in video tool handlers Video tool functions (_tool_video_ball_detection, _tool_video_pitch_detection, _tool_video_radar) were wrapping results in {'success': True, 'result': ...} while _run_video_tool already wrapped the same way, causing double nesting. Also fix test expectations to unwrap single result envelope correctly. Amp-Thread-ID: https://ampcode.com/threads/T-019cb5d7-0737-7619-91f7-381f01705cd6 Co-authored-by: Amp --- .../src/forgesyte_yolo_tracker/plugin.py | 12 ++++---- .../tests_contract/test_video_tools.py | 28 +++++++++++++------ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py index c95d135..da18d13 100644 --- a/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py +++ b/plugins/forgesyte-yolo-tracker/src/forgesyte_yolo_tracker/plugin.py @@ -339,13 +339,13 @@ def handle_frame(result): } return {"xyxy": [], "confidence": [], "class_id": []} - return {"success": True, "result": _run_video_tool( + return _run_video_tool( model=model, video_path=video_path, progress_callback=progress_callback, frame_handler=handle_frame, device=device, - )} + ) def _tool_video_pitch_detection( @@ -373,13 +373,13 @@ def handle_frame(result): } return {"keypoints_xy": [], "keypoints_conf": []} - return {"success": True, "result": _run_video_tool( + return _run_video_tool( model=model, video_path=video_path, progress_callback=progress_callback, frame_handler=handle_frame, device=device, - )} + ) def _tool_video_radar( @@ -409,13 +409,13 @@ def handle_frame(result): } return {"xyxy": [], "centers": [], "confidence": [], "class_id": []} - return {"success": True, "result": _run_video_tool( + return _run_video_tool( model=model, video_path=video_path, progress_callback=progress_callback, frame_handler=handle_frame, device=device, - )} + ) def _tool_video_player_tracking( diff --git a/plugins/forgesyte-yolo-tracker/tests_contract/test_video_tools.py b/plugins/forgesyte-yolo-tracker/tests_contract/test_video_tools.py index 7e681fb..4865e23 100644 --- a/plugins/forgesyte-yolo-tracker/tests_contract/test_video_tools.py +++ b/plugins/forgesyte-yolo-tracker/tests_contract/test_video_tools.py @@ -99,8 +99,10 @@ def test_returns_dict_with_frames_and_total_frames(self) -> None: ) assert isinstance(result, dict) - assert "frames" in result - assert "total_frames" in result + assert result.get("success") is True + data = result.get("result", {}) + assert "frames" in data + assert "total_frames" in data def test_frames_have_correct_structure(self) -> None: """Verify each frame has frame_index and detections.""" @@ -119,7 +121,9 @@ def test_frames_have_correct_structure(self) -> None: {"video_path": "/tmp/test.mp4", "device": "cpu"}, ) - frames = result["frames"] + # Unwrap the result envelope + data = result.get("result", {}) + frames = data["frames"] assert isinstance(frames, list) assert len(frames) == 2 @@ -154,8 +158,10 @@ def test_total_frames_is_correct(self) -> None: {"video_path": "/tmp/test.mp4", "device": "cpu"}, ) - assert result["total_frames"] == 3 - assert len(result["frames"]) == 3 + # Unwrap the result envelope + data = result.get("result", {}) + assert data["total_frames"] == 3 + assert len(data["frames"]) == 3 def test_empty_video_returns_empty_frames(self) -> None: """Verify tool handles empty video (no frames).""" @@ -169,8 +175,10 @@ def test_empty_video_returns_empty_frames(self) -> None: {"video_path": "/tmp/empty.mp4", "device": "cpu"}, ) - assert result["frames"] == [] - assert result["total_frames"] == 0 + # Unwrap the result envelope + data = result.get("result", {}) + assert data["frames"] == [] + assert data["total_frames"] == 0 def test_uses_stream_mode(self) -> None: """Verify tool processes frames sequentially (streaming mode behavior).""" @@ -190,9 +198,11 @@ def test_uses_stream_mode(self) -> None: {"video_path": "/tmp/test.mp4", "device": "cpu"}, ) + # Unwrap the result envelope + data = result.get("result", {}) # Verify all frames were processed (streaming yields each frame) - assert len(result["frames"]) == 2 - assert result["total_frames"] == 2 + assert len(data["frames"]) == 2 + assert data["total_frames"] == 2 class TestVideoToolDevice: