streamvision 0.1.0rc1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. streamvision-0.1.0rc1/LICENSE +7 -0
  2. streamvision-0.1.0rc1/PKG-INFO +42 -0
  3. streamvision-0.1.0rc1/README.md +13 -0
  4. streamvision-0.1.0rc1/pyproject.toml +34 -0
  5. streamvision-0.1.0rc1/setup.cfg +4 -0
  6. streamvision-0.1.0rc1/streamvision/__init__.py +1 -0
  7. streamvision-0.1.0rc1/streamvision/camera/__init__.py +0 -0
  8. streamvision-0.1.0rc1/streamvision/camera/buffer_strategies.py +28 -0
  9. streamvision-0.1.0rc1/streamvision/camera/camera.py +138 -0
  10. streamvision-0.1.0rc1/streamvision/camera/collection_policy.py +377 -0
  11. streamvision-0.1.0rc1/streamvision/camera/dgpu_producer.py +114 -0
  12. streamvision-0.1.0rc1/streamvision/camera/discoverability.py +349 -0
  13. streamvision-0.1.0rc1/streamvision/camera/entities.py +117 -0
  14. streamvision-0.1.0rc1/streamvision/camera/exceptions.py +14 -0
  15. streamvision-0.1.0rc1/streamvision/camera/gstreamer_cuda_producer.py +539 -0
  16. streamvision-0.1.0rc1/streamvision/camera/gstreamer_cuda_tensor_bridge.py +308 -0
  17. streamvision-0.1.0rc1/streamvision/camera/gstreamer_rtsp_pipeline.py +175 -0
  18. streamvision-0.1.0rc1/streamvision/camera/gstreamer_rtsp_producer.py +81 -0
  19. streamvision-0.1.0rc1/streamvision/camera/jetson_producer.py +566 -0
  20. streamvision-0.1.0rc1/streamvision/camera/jetson_tensor_bridge.py +348 -0
  21. streamvision-0.1.0rc1/streamvision/camera/rtsp_opencv_tls.py +104 -0
  22. streamvision-0.1.0rc1/streamvision/camera/rtsp_tls.py +38 -0
  23. streamvision-0.1.0rc1/streamvision/camera/source_reference_sanitizer.py +89 -0
  24. streamvision-0.1.0rc1/streamvision/camera/source_reference_validation.py +62 -0
  25. streamvision-0.1.0rc1/streamvision/camera/stream_error_classifier.py +549 -0
  26. streamvision-0.1.0rc1/streamvision/camera/stream_error_codes.py +13 -0
  27. streamvision-0.1.0rc1/streamvision/camera/test_pattern_producer.py +206 -0
  28. streamvision-0.1.0rc1/streamvision/camera/utils.py +573 -0
  29. streamvision-0.1.0rc1/streamvision/camera/video_source.py +1453 -0
  30. streamvision-0.1.0rc1/streamvision/stream/__init__.py +0 -0
  31. streamvision-0.1.0rc1/streamvision/stream/configuration.py +168 -0
  32. streamvision-0.1.0rc1/streamvision/stream/entities.py +154 -0
  33. streamvision-0.1.0rc1/streamvision/stream/environment.py +72 -0
  34. streamvision-0.1.0rc1/streamvision/stream/exceptions.py +33 -0
  35. streamvision-0.1.0rc1/streamvision/stream/model_handlers/__init__.py +0 -0
  36. streamvision-0.1.0rc1/streamvision/stream/model_handlers/workflows.py +339 -0
  37. streamvision-0.1.0rc1/streamvision/stream/pipeline.py +924 -0
  38. streamvision-0.1.0rc1/streamvision/stream/session.py +30 -0
  39. streamvision-0.1.0rc1/streamvision/stream/sinks.py +592 -0
  40. streamvision-0.1.0rc1/streamvision/stream/support/__init__.py +6 -0
  41. streamvision-0.1.0rc1/streamvision/stream/support/async_queue.py +87 -0
  42. streamvision-0.1.0rc1/streamvision/stream/support/decorators.py +26 -0
  43. streamvision-0.1.0rc1/streamvision/stream/support/environment.py +59 -0
  44. streamvision-0.1.0rc1/streamvision/stream/support/images.py +245 -0
  45. streamvision-0.1.0rc1/streamvision/stream/utils.py +199 -0
  46. streamvision-0.1.0rc1/streamvision/stream/warnings.py +9 -0
  47. streamvision-0.1.0rc1/streamvision/stream/watchdog.py +301 -0
  48. streamvision-0.1.0rc1/streamvision/stream_manager/__init__.py +0 -0
  49. streamvision-0.1.0rc1/streamvision/stream_manager/api/__init__.py +0 -0
  50. streamvision-0.1.0rc1/streamvision/stream_manager/api/entities.py +47 -0
  51. streamvision-0.1.0rc1/streamvision/stream_manager/api/errors.py +52 -0
  52. streamvision-0.1.0rc1/streamvision/stream_manager/api/stream_manager_client.py +373 -0
  53. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/__init__.py +0 -0
  54. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/app.py +659 -0
  55. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/bootstrap.py +126 -0
  56. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/communication.py +83 -0
  57. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/entities.py +156 -0
  58. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/errors.py +44 -0
  59. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/host.py +158 -0
  60. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/inference_pipeline_manager.py +793 -0
  61. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/result_serialization.py +64 -0
  62. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/serialisation.py +63 -0
  63. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/tcp_server.py +19 -0
  64. streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/webrtc.py +493 -0
  65. streamvision-0.1.0rc1/streamvision.egg-info/PKG-INFO +42 -0
  66. streamvision-0.1.0rc1/streamvision.egg-info/SOURCES.txt +67 -0
  67. streamvision-0.1.0rc1/streamvision.egg-info/dependency_links.txt +1 -0
  68. streamvision-0.1.0rc1/streamvision.egg-info/requires.txt +17 -0
  69. streamvision-0.1.0rc1/streamvision.egg-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ LICENSE.core (Apache 2.0) applies to all files in this repository
2
+ except for files in or under any directory that contains a superseding
3
+ license file (such as the models located in `inference/models/`
4
+ which are governed by their own individual licenses and
5
+ the files and folders in the `inference/enterprise/` and `inference_cli/lib/enterprise/` directories which are
6
+ governed by the Roboflow Enterprise License located at
7
+ `inference/enterprise/LICENSE.txt`).
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamvision
3
+ Version: 0.1.0rc1
4
+ Summary: Video sources, InferencePipeline and the stream manager for Roboflow Inference
5
+ License: LICENSE.core (Apache 2.0) applies to all files in this repository
6
+ except for files in or under any directory that contains a superseding
7
+ license file (such as the models located in `inference/models/`
8
+ which are governed by their own individual licenses and
9
+ the files and folders in the `inference/enterprise/` and `inference_cli/lib/enterprise/` directories which are
10
+ governed by the Roboflow Enterprise License located at
11
+ `inference/enterprise/LICENSE.txt`).
12
+ Requires-Python: <3.14,>=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: numpy<2.4.0,>=2.0.0
16
+ Requires-Dist: opencv-python<4.13.0,>=4.8.1.78
17
+ Requires-Dist: supervision<0.30.0,>=0.29.0
18
+ Requires-Dist: pydantic<2.12.0,>=2.8.0
19
+ Requires-Dist: psutil>=7.0.0
20
+ Requires-Dist: roboflow-workflows>=0.2.4rc1
21
+ Provides-Extra: webrtc
22
+ Requires-Dist: aiortc>=1.9.0; extra == "webrtc"
23
+ Requires-Dist: av==14.2.0; extra == "webrtc"
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest<10.0.0,>=9.0.3; extra == "test"
26
+ Requires-Dist: requests-mock~=1.12.1; extra == "test"
27
+ Requires-Dist: tomli>=2.0.0; python_version < "3.11" and extra == "test"
28
+ Dynamic: license-file
29
+
30
+ # streamvision
31
+
32
+ `streamvision` holds the host-neutral pieces of Roboflow Inference's video
33
+ stack: camera acquisition (`VideoSource` and its producers), the host-neutral
34
+ `InferencePipeline`, the stream-manager TCP client and its wire entities, and
35
+ the stream-manager runtime that hosts pipelines as subprocesses. It is a
36
+ sibling distribution to `roboflow-workflows`, installable on its own or
37
+ embedded in the full Inference server.
38
+
39
+ The historical `inference.core.interfaces.{camera,stream,stream_manager}`
40
+ import paths resolve to the same modules here when `inference` is installed.
41
+ No `gpu`/`jetson` extra is declared: GStreamer/NVDEC support is provisioned by
42
+ the Docker images, not pinned as a PyPI dependency in `requirements/`.
@@ -0,0 +1,13 @@
1
+ # streamvision
2
+
3
+ `streamvision` holds the host-neutral pieces of Roboflow Inference's video
4
+ stack: camera acquisition (`VideoSource` and its producers), the host-neutral
5
+ `InferencePipeline`, the stream-manager TCP client and its wire entities, and
6
+ the stream-manager runtime that hosts pipelines as subprocesses. It is a
7
+ sibling distribution to `roboflow-workflows`, installable on its own or
8
+ embedded in the full Inference server.
9
+
10
+ The historical `inference.core.interfaces.{camera,stream,stream_manager}`
11
+ import paths resolve to the same modules here when `inference` is installed.
12
+ No `gpu`/`jetson` extra is declared: GStreamer/NVDEC support is provisioned by
13
+ the Docker images, not pinned as a PyPI dependency in `requirements/`.
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "streamvision"
3
+ version = "0.1.0rc1"
4
+ description = "Video sources, InferencePipeline and the stream manager for Roboflow Inference"
5
+ readme = "README.md"
6
+ license = {file = "LICENSE"}
7
+ requires-python = ">=3.10,<3.14"
8
+ dependencies = [
9
+ "numpy>=2.0.0,<2.4.0",
10
+ "opencv-python>=4.8.1.78,<4.13.0",
11
+ "supervision>=0.29.0,<0.30.0",
12
+ "pydantic>=2.8.0,<2.12.0",
13
+ "psutil>=7.0.0",
14
+ "roboflow-workflows>=0.2.4rc1",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ webrtc = [
19
+ "aiortc>=1.9.0",
20
+ "av==14.2.0",
21
+ ]
22
+ test = [
23
+ "pytest>=9.0.3,<10.0.0",
24
+ "requests-mock~=1.12.1",
25
+ "tomli>=2.0.0; python_version < '3.11'",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["setuptools>=83.0.0", "wheel"]
30
+ build-backend = "setuptools.build_meta"
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["streamvision*"]
34
+ exclude = ["tests*", "build_scripts*", "scripts*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Camera acquisition, InferencePipeline, and the stream-manager client and runtime."""
File without changes
@@ -0,0 +1,28 @@
1
+ """Buffer strategies of a video source, importable without the decoder.
2
+
3
+ Request entities validate these values without needing cv2 or the video
4
+ source itself. `video_source` re-exports both enums, and each class keeps the
5
+ `__module__` it was historically defined in, so signatures, annotations and
6
+ pickles still name `video_source` exactly as before the extraction.
7
+ """
8
+
9
+ from enum import Enum
10
+
11
+ _HISTORICAL_MODULE = f"{__name__.rsplit('.', 1)[0]}.video_source"
12
+
13
+
14
+ class BufferFillingStrategy(str, Enum):
15
+ WAIT = "WAIT"
16
+ DROP_OLDEST = "DROP_OLDEST"
17
+ ADAPTIVE_DROP_OLDEST = "ADAPTIVE_DROP_OLDEST"
18
+ DROP_LATEST = "DROP_LATEST"
19
+ ADAPTIVE_DROP_LATEST = "ADAPTIVE_DROP_LATEST"
20
+
21
+
22
+ class BufferConsumptionStrategy(str, Enum):
23
+ LAZY = "LAZY"
24
+ EAGER = "EAGER"
25
+
26
+
27
+ BufferFillingStrategy.__module__ = _HISTORICAL_MODULE
28
+ BufferConsumptionStrategy.__module__ = _HISTORICAL_MODULE
@@ -0,0 +1,138 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ from threading import Thread
5
+
6
+ import cv2
7
+ from PIL import Image
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class WebcamStream:
13
+ """Class to handle webcam streaming using a separate thread.
14
+
15
+ Attributes:
16
+ stream_id (int): The ID of the webcam stream.
17
+ frame_id (int): A counter for the current frame.
18
+ vcap (VideoCapture): OpenCV video capture object.
19
+ width (int): The width of the video frame.
20
+ height (int): The height of the video frame.
21
+ fps_input_stream (int): Frames per second of the input stream.
22
+ grabbed (bool): A flag indicating if a frame was successfully grabbed.
23
+ frame (array): The current frame as a NumPy array.
24
+ pil_image (Image): The current frame as a PIL image.
25
+ stopped (bool): A flag indicating if the stream is stopped.
26
+ t (Thread): The thread used to update the stream.
27
+ """
28
+
29
+ def __init__(self, stream_id=0, enforce_fps=False):
30
+ """Initialize the webcam stream.
31
+
32
+ Args:
33
+ stream_id (int, optional): The ID of the webcam stream. Defaults to 0.
34
+ """
35
+ self.stream_id = stream_id
36
+ self.enforce_fps = enforce_fps
37
+ self.frame_id = 0
38
+ self.vcap = cv2.VideoCapture(self.stream_id)
39
+
40
+ for key in os.environ:
41
+ if key.startswith("CV2_CAP_PROP"):
42
+ opencv_prop = key[4:]
43
+ opencv_constant = getattr(cv2, opencv_prop, None)
44
+ if opencv_constant is not None:
45
+ value = int(os.getenv(key))
46
+ self.vcap.set(opencv_constant, value)
47
+ logger.info(f"set {opencv_prop} to {value}")
48
+ else:
49
+ logger.warning(f"Property {opencv_prop} not found in cv2")
50
+
51
+ self.width = int(self.vcap.get(cv2.CAP_PROP_FRAME_WIDTH))
52
+ self.height = int(self.vcap.get(cv2.CAP_PROP_FRAME_HEIGHT))
53
+ self.file_mode = self.vcap.get(cv2.CAP_PROP_FRAME_COUNT) > 0
54
+ if self.enforce_fps and not self.file_mode:
55
+ logger.warning(
56
+ "Ignoring enforce_fps flag for this stream. It is not compatible with streams and will cause the process to crash"
57
+ )
58
+ self.enforce_fps = False
59
+ self.max_fps = None
60
+ if self.vcap.isOpened() is False:
61
+ logger.debug("[Exiting]: Error accessing webcam stream.")
62
+ exit(0)
63
+ self.fps_input_stream = int(self.vcap.get(cv2.CAP_PROP_FPS))
64
+ logger.debug(
65
+ "FPS of webcam hardware/input stream: {}".format(self.fps_input_stream)
66
+ )
67
+ self.grabbed, self.frame = self.vcap.read()
68
+ self.pil_image = Image.fromarray(cv2.cvtColor(self.frame, cv2.COLOR_BGR2RGB))
69
+ if self.grabbed is False:
70
+ logger.debug("[Exiting] No more frames to read")
71
+ exit(0)
72
+ self.stopped = True
73
+ self.t = Thread(target=self.update, args=())
74
+ self.t.daemon = True
75
+
76
+ def start(self):
77
+ """Start the thread for reading frames."""
78
+ self.stopped = False
79
+ self.t.start()
80
+
81
+ def update(self):
82
+ """Update the frame by reading from the webcam."""
83
+ frame_id = 0
84
+ next_frame_time = 0
85
+ t0 = time.perf_counter()
86
+ while True:
87
+ t1 = time.perf_counter()
88
+ if self.stopped is True:
89
+ break
90
+
91
+ self.grabbed = self.vcap.grab()
92
+ if self.grabbed is False:
93
+ logger.debug("[Exiting] No more frames to read")
94
+ self.stopped = True
95
+ break
96
+ frame_id += 1
97
+ # We can't retrieve each frame on nano and other lower powered devices quickly enough to keep up with the stream.
98
+ # By default, we will only retrieve frames when we'll be ready process them (determined by self.max_fps).
99
+ if t1 > next_frame_time:
100
+ ret, frame = self.vcap.retrieve()
101
+ if frame is None:
102
+ logger.debug("[Exiting] Frame not available for read")
103
+ self.stopped = True
104
+ break
105
+ logger.debug(
106
+ f"retrieved frame {frame_id}, effective FPS: {frame_id / (t1 - t0):.2f}"
107
+ )
108
+ self.frame_id = frame_id
109
+ self.frame = frame
110
+ while self.file_mode and self.enforce_fps and self.max_fps is None:
111
+ # sleep until we have processed the first frame and we know what our FPS should be
112
+ time.sleep(0.01)
113
+ if self.max_fps is None:
114
+ self.max_fps = 30
115
+ next_frame_time = t1 + (1 / self.max_fps) + 0.02
116
+ if self.file_mode:
117
+ t2 = time.perf_counter()
118
+ if self.enforce_fps:
119
+ # when enforce_fps is true, grab video frames 1:1 with inference speed
120
+ time_to_sleep = next_frame_time - t2
121
+ else:
122
+ # otherwise, grab at native FPS of the video file
123
+ time_to_sleep = (1 / self.fps_input_stream) - (t2 - t1)
124
+ if time_to_sleep > 0:
125
+ time.sleep(time_to_sleep)
126
+ self.vcap.release()
127
+
128
+ def read_opencv(self):
129
+ """Read the current frame using OpenCV.
130
+
131
+ Returns:
132
+ array, int: The current frame as a NumPy array, and the frame ID.
133
+ """
134
+ return self.frame, self.frame_id
135
+
136
+ def stop(self):
137
+ """Stop the webcam stream."""
138
+ self.stopped = True
@@ -0,0 +1,377 @@
1
+ """Collection policies for multi-source video consumption in InferencePipeline.
2
+
3
+ Motivation (measured on Jetson AGX Orin, 8x 2K@15 RTSP): consumer cameras emit
4
+ frames in bursts separated by encoder pauses of 400-500 ms around every
5
+ I-frame. The legacy blocking batch collection waits for a fresh frame from
6
+ EVERY source per cycle, so with several staggered cameras almost every batch
7
+ stalls on whichever source is inside its pause - throughput collapses to a
8
+ fraction of the aggregate frame rate while decoded frames are silently
9
+ discarded. The policies in this module remove that coupling:
10
+
11
+ * the batch-collection window self-tunes from the pipeline's own rhythms
12
+ instead of a hand-picked ``batch_collection_timeout``: once per-source
13
+ arrival rates are measured, the round period is matched to the fastest
14
+ live source's frame period (full batches whenever the model has headroom,
15
+ floor-window under saturation); before estimates exist it falls back to a
16
+ fraction of the measured execution time,
17
+ * consumption is FIFO with a bounded staleness budget - every frame is served
18
+ while the consumer keeps up, and under overload served frames are never
19
+ older than ``max_staleness`` (drops are counted and reported, not silent).
20
+
21
+ File sources are exempt from the staleness budget by design: file decoding is
22
+ demand-paced (a "stale" frame only means the consumer was busy) and dropping
23
+ frames from a file would silently corrupt every-frame processing guarantees.
24
+ """
25
+
26
+ import logging
27
+ from collections import deque
28
+ from datetime import datetime
29
+ from enum import Enum
30
+ from time import monotonic
31
+ from typing import TYPE_CHECKING, Callable, Deque, Dict, Optional, Union
32
+
33
+ from streamvision.camera.entities import VideoFrame
34
+ from streamvision.stream import environment as streams_environment
35
+
36
+ if TYPE_CHECKING: # pragma: no cover - typing only
37
+ from streamvision.camera.video_source import VideoSource
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ DEFAULT_MAX_STALENESS_SECONDS = 0.5
42
+ MIN_COLLECTION_WINDOW_SECONDS = 0.002
43
+ MAX_COLLECTION_WINDOW_SECONDS = 0.030
44
+ COLLECTION_WINDOW_EXECUTION_FRACTION = 0.2
45
+ EXECUTION_GAP_EMA_ALPHA = 0.2
46
+ FRESHEST_MODE_BATCH_COLLECTION_TIMEOUT = 0.02
47
+ STALENESS_DROP_CAUSE = "STALENESS_BUDGET_EXCEEDED"
48
+ LEGACY_MODE_ALIASES = frozenset({"legacy", "none"})
49
+ # Rate-matched window: cap and the arrival-period estimator's shape. The
50
+ # estimator is count-over-span (never an EMA of gaps - bursty encoders like
51
+ # consumer RTSP cameras emit 2-frame clusters around GOP pauses, and gap
52
+ # EMAs oscillate at the burst frequency while a span over several burst
53
+ # cycles converges on the true rate).
54
+ RATE_MATCHED_WINDOW_CAP_SECONDS = 0.1
55
+ # Give the first model invocation a bounded chance to receive the full live
56
+ # cohort. Shape-specialised runtimes such as TensorRT can otherwise spend their
57
+ # cold start preparing a tiny partial-batch plan before arrival estimates exist.
58
+ # After the first non-empty round, the execution/rate controller takes over.
59
+ INITIAL_COLLECTION_WINDOW_SECONDS = RATE_MATCHED_WINDOW_CAP_SECONDS
60
+ ARRIVAL_PERIOD_SAMPLE_WINDOW = 64
61
+ MIN_ARRIVAL_SAMPLES_TO_TRUST = 16
62
+ SOURCE_ACTIVITY_HORIZON_SECONDS = 2.0
63
+
64
+
65
+ class VideoProcessingMode(str, Enum):
66
+ """High-level intent for live multi-source consumption.
67
+
68
+ * AUTO - FIFO consumption with a staleness budget and a self-tuning
69
+ collection window. Serves every frame while the consumer keeps up;
70
+ under overload degrades to freshest-at-capacity with bounded,
71
+ reported drops.
72
+ * EVERY_FRAME - AUTO's machinery with the staleness budget disabled:
73
+ strict FIFO for live sources (under sustained overload latency pins
74
+ at the decoding-buffer depth).
75
+ * FRESHEST - legacy latest-wins semantics (EAGER consumption) with a
76
+ small fixed collection timeout; minimal latency, silent skipping.
77
+ """
78
+
79
+ AUTO = "auto"
80
+ EVERY_FRAME = "every_frame"
81
+ FRESHEST = "freshest"
82
+
83
+
84
+ def resolve_video_processing_mode(
85
+ explicit_mode: Optional[Union[str, VideoProcessingMode]],
86
+ ) -> Optional[VideoProcessingMode]:
87
+ """Resolve the effective processing mode.
88
+
89
+ Order: explicit argument > tensor-representation cohort default (AUTO
90
+ when ``ENABLE_TENSOR_DATA_REPRESENTATION`` is set - the same opt-in
91
+ boundary that already gates decoding-buffer depth) > ``None`` meaning
92
+ the legacy collection behavior, byte-for-byte. The explicit strings
93
+ ``"legacy"`` / ``"none"`` force the legacy behavior even inside the
94
+ tensor cohort - the escape hatch from the flag-driven AUTO default.
95
+ """
96
+ if explicit_mode is not None:
97
+ if (
98
+ isinstance(explicit_mode, str)
99
+ and explicit_mode.lower() in LEGACY_MODE_ALIASES
100
+ ):
101
+ return None
102
+ return VideoProcessingMode(explicit_mode)
103
+ if streams_environment.ENABLE_TENSOR_DATA_REPRESENTATION:
104
+ return VideoProcessingMode.AUTO
105
+ return None
106
+
107
+
108
+ class _SourceArrivalEstimator:
109
+ """Count-over-span estimate of one live source's true frame period.
110
+
111
+ Fed with ingress capture timestamps (``VideoFrame.frame_timestamp`` is
112
+ stamped on the decode thread), so downstream queueing cannot distort the
113
+ span. A gap longer than the activity horizon (reconnect, stall) clears
114
+ the window - a rejoin gap is not a frame period - and the estimate stays
115
+ ``None`` until enough fresh samples accumulate again.
116
+ """
117
+
118
+ def __init__(self, sample_window: int = ARRIVAL_PERIOD_SAMPLE_WINDOW):
119
+ self._timestamps: Deque[datetime] = deque(maxlen=sample_window)
120
+ self._last_seen_at: Optional[float] = None
121
+
122
+ def observe(self, frame_timestamp: datetime, now: float) -> None:
123
+ if self._timestamps:
124
+ gap = (frame_timestamp - self._timestamps[-1]).total_seconds()
125
+ if gap > SOURCE_ACTIVITY_HORIZON_SECONDS or gap < 0:
126
+ self._timestamps.clear()
127
+ self._timestamps.append(frame_timestamp)
128
+ self._last_seen_at = now
129
+
130
+ def period(self, now: float) -> Optional[float]:
131
+ if (
132
+ self._last_seen_at is None
133
+ or now - self._last_seen_at > SOURCE_ACTIVITY_HORIZON_SECONDS
134
+ ):
135
+ return None
136
+ if len(self._timestamps) < MIN_ARRIVAL_SAMPLES_TO_TRUST:
137
+ return None
138
+ span = (self._timestamps[-1] - self._timestamps[0]).total_seconds()
139
+ if span <= 0:
140
+ return None
141
+ return span / (len(self._timestamps) - 1)
142
+
143
+
144
+ class AdaptiveWindowController:
145
+ """Self-tunes the batch-collection window from the collection rhythm.
146
+
147
+ Two regimes, picked per round by whether a trustworthy arrival-period
148
+ estimate exists:
149
+
150
+ * RATE-MATCHED (estimate available): ``window = clamp(min live source
151
+ period - exec EMA, floor, cap)``. The round period then equals the
152
+ fastest source's frame period, so in the light-load regime every
153
+ round finds each source with exactly one fresh frame - full batches
154
+ by construction. Under saturation (exec >= period) the subtraction
155
+ goes negative and the window floors, which is the correct move: every
156
+ source already has frames queued when the round starts. The added
157
+ collection wait is offset by removed queue wait (a frame that misses
158
+ a round today sits in the LAZY queue for a full round period), so
159
+ end-to-end latency stays roughly flat while occupancy rises.
160
+ * FALLBACK (startup, no live estimates): a fraction of the exec-time
161
+ EMA, clamped - light models get a near-zero window, heavy models a
162
+ larger alignment window.
163
+
164
+ The gap between a non-empty collection finishing and the next collection
165
+ starting is, by construction, the batch execution time.
166
+ """
167
+
168
+ def __init__(
169
+ self,
170
+ alpha: float = EXECUTION_GAP_EMA_ALPHA,
171
+ execution_fraction: float = COLLECTION_WINDOW_EXECUTION_FRACTION,
172
+ min_window: float = MIN_COLLECTION_WINDOW_SECONDS,
173
+ max_window: float = MAX_COLLECTION_WINDOW_SECONDS,
174
+ initial_window: float = INITIAL_COLLECTION_WINDOW_SECONDS,
175
+ rate_matched_cap: float = RATE_MATCHED_WINDOW_CAP_SECONDS,
176
+ clock: Callable[[], float] = monotonic,
177
+ ):
178
+ self._alpha = alpha
179
+ self._execution_fraction = execution_fraction
180
+ self._min_window = min_window
181
+ self._max_window = max_window
182
+ self._rate_matched_cap = rate_matched_cap
183
+ self._window = initial_window
184
+ self._clock = clock
185
+ self._execution_gap_ema: Optional[float] = None
186
+ self._last_non_empty_collection_end: Optional[float] = None
187
+
188
+ def on_collection_start(
189
+ self, minimum_arrival_period: Optional[float] = None
190
+ ) -> float:
191
+ now = self._clock()
192
+ if self._last_non_empty_collection_end is not None:
193
+ execution_gap = now - self._last_non_empty_collection_end
194
+ if self._execution_gap_ema is None:
195
+ self._execution_gap_ema = execution_gap
196
+ else:
197
+ self._execution_gap_ema = (
198
+ 1 - self._alpha
199
+ ) * self._execution_gap_ema + self._alpha * execution_gap
200
+ if minimum_arrival_period is not None:
201
+ rate_matched = minimum_arrival_period - self._execution_gap_ema
202
+ self._window = min(
203
+ max(rate_matched, self._min_window),
204
+ self._rate_matched_cap,
205
+ )
206
+ else:
207
+ self._window = min(
208
+ max(
209
+ self._execution_fraction * self._execution_gap_ema,
210
+ self._min_window,
211
+ ),
212
+ self._max_window,
213
+ )
214
+ return self._window
215
+
216
+ def on_collection_end(self, collected_any_frame: bool) -> None:
217
+ self._last_non_empty_collection_end = (
218
+ self._clock() if collected_any_frame else None
219
+ )
220
+
221
+ @property
222
+ def window(self) -> float:
223
+ return self._window
224
+
225
+ @property
226
+ def execution_gap_ema(self) -> Optional[float]:
227
+ return self._execution_gap_ema
228
+
229
+
230
+ class CollectionPolicy:
231
+ """Per-round collection behavior for AUTO / EVERY_FRAME modes.
232
+
233
+ Live sources are read FIFO with frames older than ``max_staleness``
234
+ dropped (counted, optionally reported via ``on_frame_dropped``). File
235
+ sources - and sources whose properties are not yet known - are read
236
+ as-is and NEVER dropped; liveness is resolved lazily from
237
+ ``VideoSource.describe_source()`` and cached once known.
238
+ """
239
+
240
+ def __init__(
241
+ self,
242
+ mode: VideoProcessingMode,
243
+ max_staleness: Optional[float] = None,
244
+ on_frame_dropped: Optional[Callable[[VideoFrame], None]] = None,
245
+ window_controller: Optional[AdaptiveWindowController] = None,
246
+ ):
247
+ if mode is VideoProcessingMode.FRESHEST:
248
+ raise ValueError(
249
+ "FRESHEST mode is realised through EAGER buffer consumption "
250
+ "and does not use CollectionPolicy"
251
+ )
252
+ self._mode = mode
253
+ if mode is VideoProcessingMode.EVERY_FRAME:
254
+ self._max_staleness = None
255
+ else:
256
+ self._max_staleness = (
257
+ DEFAULT_MAX_STALENESS_SECONDS
258
+ if max_staleness is None
259
+ else max_staleness
260
+ )
261
+ self._on_frame_dropped = on_frame_dropped
262
+ self._window_controller = window_controller or AdaptiveWindowController()
263
+ self._source_is_file: Dict[int, bool] = {}
264
+ self._frames_dropped_on_staleness: Dict[int, int] = {}
265
+ self._arrival_estimators: Dict[int, _SourceArrivalEstimator] = {}
266
+
267
+ @property
268
+ def mode(self) -> VideoProcessingMode:
269
+ return self._mode
270
+
271
+ @property
272
+ def max_staleness(self) -> Optional[float]:
273
+ return self._max_staleness
274
+
275
+ @property
276
+ def frames_dropped_on_staleness(self) -> Dict[int, int]:
277
+ return dict(self._frames_dropped_on_staleness)
278
+
279
+ def collection_window(self) -> float:
280
+ return self._window_controller.on_collection_start(
281
+ minimum_arrival_period=self.minimum_live_arrival_period()
282
+ )
283
+
284
+ def minimum_live_arrival_period(self) -> Optional[float]:
285
+ """Smallest trustworthy frame period across ACTIVE live sources.
286
+
287
+ The fastest source is the binding constraint for the rate-matched
288
+ window: a round period above ANY source's frame period makes that
289
+ source queue unboundedly. Files never contribute (demand-paced,
290
+ always ready) and dormant sources drop out via the activity horizon
291
+ so a dying camera cannot pin the window while it flaps.
292
+ """
293
+ now = monotonic()
294
+ periods = [
295
+ period
296
+ for period in (
297
+ estimator.period(now) for estimator in self._arrival_estimators.values()
298
+ )
299
+ if period is not None
300
+ ]
301
+ if not periods:
302
+ return None
303
+ return min(periods)
304
+
305
+ def note_collection_result(self, batch_frames: list) -> None:
306
+ self._window_controller.on_collection_end(
307
+ collected_any_frame=bool(batch_frames)
308
+ )
309
+
310
+ def read_frame(
311
+ self,
312
+ source_ord: int,
313
+ source: "VideoSource",
314
+ timeout: Optional[float],
315
+ ) -> Optional[VideoFrame]:
316
+ treated_as_file = self._source_treated_as_file(
317
+ source_ord=source_ord, source=source
318
+ )
319
+ if self._max_staleness is None or treated_as_file:
320
+ frame = source.read_frame(timeout=timeout)
321
+ if frame is not None and not treated_as_file:
322
+ self._observe_arrival(source_ord=source_ord, frame=frame)
323
+ return frame
324
+ deadline = None if timeout is None else monotonic() + timeout
325
+ while True:
326
+ remaining = None if deadline is None else max(deadline - monotonic(), 0.0)
327
+ frame = source.read_frame(timeout=remaining)
328
+ if frame is None:
329
+ return None
330
+ # Staleness-drained frames feed the estimator too: they are real
331
+ # arrivals, and skipping them would bias the period estimate
332
+ # upward exactly when the pipeline is busiest.
333
+ self._observe_arrival(source_ord=source_ord, frame=frame)
334
+ frame_age = (datetime.now() - frame.frame_timestamp).total_seconds()
335
+ if frame_age <= self._max_staleness:
336
+ return frame
337
+ self._register_staleness_drop(source_ord=source_ord, frame=frame)
338
+
339
+ def _observe_arrival(self, source_ord: int, frame: VideoFrame) -> None:
340
+ estimator = self._arrival_estimators.get(source_ord)
341
+ if estimator is None:
342
+ estimator = _SourceArrivalEstimator()
343
+ self._arrival_estimators[source_ord] = estimator
344
+ estimator.observe(frame_timestamp=frame.frame_timestamp, now=monotonic())
345
+
346
+ def _source_treated_as_file(self, source_ord: int, source: "VideoSource") -> bool:
347
+ cached = self._source_is_file.get(source_ord)
348
+ if cached is not None:
349
+ return cached
350
+ is_file: Optional[bool] = None
351
+ try:
352
+ source_metadata = source.describe_source()
353
+ source_properties = source_metadata.source_properties
354
+ if source_properties is not None:
355
+ is_file = source_properties.is_file
356
+ except Exception: # noqa: BLE001 - metadata probe must never break reads
357
+ is_file = None
358
+ if is_file is None:
359
+ # Liveness unknown (source still initialising) - never drop.
360
+ return True
361
+ self._source_is_file[source_ord] = is_file
362
+ return is_file
363
+
364
+ def _register_staleness_drop(self, source_ord: int, frame: VideoFrame) -> None:
365
+ self._frames_dropped_on_staleness[source_ord] = (
366
+ self._frames_dropped_on_staleness.get(source_ord, 0) + 1
367
+ )
368
+ if self._on_frame_dropped is None:
369
+ return
370
+ try:
371
+ self._on_frame_dropped(frame)
372
+ except Exception: # noqa: BLE001 - reporting must never break collection
373
+ logger.warning(
374
+ "on_frame_dropped callback raised while reporting a staleness "
375
+ "drop for source %s",
376
+ frame.source_id,
377
+ )