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.
- streamvision-0.1.0rc1/LICENSE +7 -0
- streamvision-0.1.0rc1/PKG-INFO +42 -0
- streamvision-0.1.0rc1/README.md +13 -0
- streamvision-0.1.0rc1/pyproject.toml +34 -0
- streamvision-0.1.0rc1/setup.cfg +4 -0
- streamvision-0.1.0rc1/streamvision/__init__.py +1 -0
- streamvision-0.1.0rc1/streamvision/camera/__init__.py +0 -0
- streamvision-0.1.0rc1/streamvision/camera/buffer_strategies.py +28 -0
- streamvision-0.1.0rc1/streamvision/camera/camera.py +138 -0
- streamvision-0.1.0rc1/streamvision/camera/collection_policy.py +377 -0
- streamvision-0.1.0rc1/streamvision/camera/dgpu_producer.py +114 -0
- streamvision-0.1.0rc1/streamvision/camera/discoverability.py +349 -0
- streamvision-0.1.0rc1/streamvision/camera/entities.py +117 -0
- streamvision-0.1.0rc1/streamvision/camera/exceptions.py +14 -0
- streamvision-0.1.0rc1/streamvision/camera/gstreamer_cuda_producer.py +539 -0
- streamvision-0.1.0rc1/streamvision/camera/gstreamer_cuda_tensor_bridge.py +308 -0
- streamvision-0.1.0rc1/streamvision/camera/gstreamer_rtsp_pipeline.py +175 -0
- streamvision-0.1.0rc1/streamvision/camera/gstreamer_rtsp_producer.py +81 -0
- streamvision-0.1.0rc1/streamvision/camera/jetson_producer.py +566 -0
- streamvision-0.1.0rc1/streamvision/camera/jetson_tensor_bridge.py +348 -0
- streamvision-0.1.0rc1/streamvision/camera/rtsp_opencv_tls.py +104 -0
- streamvision-0.1.0rc1/streamvision/camera/rtsp_tls.py +38 -0
- streamvision-0.1.0rc1/streamvision/camera/source_reference_sanitizer.py +89 -0
- streamvision-0.1.0rc1/streamvision/camera/source_reference_validation.py +62 -0
- streamvision-0.1.0rc1/streamvision/camera/stream_error_classifier.py +549 -0
- streamvision-0.1.0rc1/streamvision/camera/stream_error_codes.py +13 -0
- streamvision-0.1.0rc1/streamvision/camera/test_pattern_producer.py +206 -0
- streamvision-0.1.0rc1/streamvision/camera/utils.py +573 -0
- streamvision-0.1.0rc1/streamvision/camera/video_source.py +1453 -0
- streamvision-0.1.0rc1/streamvision/stream/__init__.py +0 -0
- streamvision-0.1.0rc1/streamvision/stream/configuration.py +168 -0
- streamvision-0.1.0rc1/streamvision/stream/entities.py +154 -0
- streamvision-0.1.0rc1/streamvision/stream/environment.py +72 -0
- streamvision-0.1.0rc1/streamvision/stream/exceptions.py +33 -0
- streamvision-0.1.0rc1/streamvision/stream/model_handlers/__init__.py +0 -0
- streamvision-0.1.0rc1/streamvision/stream/model_handlers/workflows.py +339 -0
- streamvision-0.1.0rc1/streamvision/stream/pipeline.py +924 -0
- streamvision-0.1.0rc1/streamvision/stream/session.py +30 -0
- streamvision-0.1.0rc1/streamvision/stream/sinks.py +592 -0
- streamvision-0.1.0rc1/streamvision/stream/support/__init__.py +6 -0
- streamvision-0.1.0rc1/streamvision/stream/support/async_queue.py +87 -0
- streamvision-0.1.0rc1/streamvision/stream/support/decorators.py +26 -0
- streamvision-0.1.0rc1/streamvision/stream/support/environment.py +59 -0
- streamvision-0.1.0rc1/streamvision/stream/support/images.py +245 -0
- streamvision-0.1.0rc1/streamvision/stream/utils.py +199 -0
- streamvision-0.1.0rc1/streamvision/stream/warnings.py +9 -0
- streamvision-0.1.0rc1/streamvision/stream/watchdog.py +301 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/__init__.py +0 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/api/__init__.py +0 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/api/entities.py +47 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/api/errors.py +52 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/api/stream_manager_client.py +373 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/__init__.py +0 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/app.py +659 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/bootstrap.py +126 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/communication.py +83 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/entities.py +156 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/errors.py +44 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/host.py +158 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/inference_pipeline_manager.py +793 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/result_serialization.py +64 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/serialisation.py +63 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/tcp_server.py +19 -0
- streamvision-0.1.0rc1/streamvision/stream_manager/manager_app/webrtc.py +493 -0
- streamvision-0.1.0rc1/streamvision.egg-info/PKG-INFO +42 -0
- streamvision-0.1.0rc1/streamvision.egg-info/SOURCES.txt +67 -0
- streamvision-0.1.0rc1/streamvision.egg-info/dependency_links.txt +1 -0
- streamvision-0.1.0rc1/streamvision.egg-info/requires.txt +17 -0
- 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 @@
|
|
|
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
|
+
)
|