OTVision 0.6.6__py3-none-any.whl → 0.6.8__py3-none-any.whl

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.
@@ -1,6 +1,8 @@
1
+ import socket
1
2
  from datetime import datetime, timedelta
2
3
  from time import sleep
3
4
  from typing import Generator
5
+ from urllib.parse import urlparse
4
6
 
5
7
  from cv2 import (
6
8
  CAP_PROP_FRAME_HEIGHT,
@@ -19,6 +21,7 @@ from OTVision.application.config import (
19
21
  StreamConfig,
20
22
  )
21
23
  from OTVision.application.configure_logger import logger
24
+ from OTVision.application.event.new_video_start import NewVideoStartEvent
22
25
  from OTVision.application.get_current_config import GetCurrentConfig
23
26
  from OTVision.detect.detected_frame_buffer import FlushEvent
24
27
  from OTVision.domain.frame import Frame
@@ -26,7 +29,8 @@ from OTVision.domain.input_source_detect import InputSourceDetect
26
29
  from OTVision.domain.time import DatetimeProvider
27
30
 
28
31
  RTSP_URL = "rtsp://127.0.0.1:8554/test"
29
- RETRY_SECONDS = 1
32
+ RETRY_SECONDS = 5
33
+ DEFAULT_READ_FAIL_THRESHOLD = 5
30
34
 
31
35
 
32
36
  class Counter:
@@ -78,12 +82,16 @@ class RtspInputSource(InputSourceDetect):
78
82
 
79
83
  def __init__(
80
84
  self,
81
- subject: Subject[FlushEvent],
85
+ subject_flush: Subject[FlushEvent],
86
+ subject_new_video_start: Subject[NewVideoStartEvent],
82
87
  datetime_provider: DatetimeProvider,
83
88
  frame_counter: Counter,
84
89
  get_current_config: GetCurrentConfig,
90
+ read_fail_threshold: int = DEFAULT_READ_FAIL_THRESHOLD,
85
91
  ) -> None:
86
- super().__init__(subject)
92
+
93
+ self.subject_flush = subject_flush
94
+ self.subject_new_video_start = subject_new_video_start
87
95
  self._datetime_provider = datetime_provider
88
96
  self._stop_capture = False
89
97
  self._frame_counter = frame_counter
@@ -93,6 +101,8 @@ class RtspInputSource(InputSourceDetect):
93
101
  self._stream_start_time: datetime = self._datetime_provider.provide()
94
102
  self._current_video_start_time = self._stream_start_time
95
103
  self._outdated = True
104
+ self._read_fail_threshold = read_fail_threshold
105
+ self._consecutive_read_fails = 0
96
106
 
97
107
  @property
98
108
  def _video_capture(self) -> VideoCapture:
@@ -119,48 +129,69 @@ class RtspInputSource(InputSourceDetect):
119
129
  def produce(self) -> Generator[Frame, None, None]:
120
130
  self._stream_start_time = self._datetime_provider.provide()
121
131
  self._current_video_start_time = self._stream_start_time
122
- while not self.should_stop():
123
- if (frame := self._read_next_frame()) is not None:
124
- self._frame_counter.increment()
125
- occurrence = self._datetime_provider.provide()
126
-
127
- if self._outdated:
128
- self._current_video_start_time = occurrence
129
- self._outdated = False
130
-
131
- yield Frame(
132
- data=convert_frame_to_rgb(frame), # YOLO expects RGB
133
- frame=self.current_frame_number,
134
- source=self.rtsp_url,
135
- output=self.create_output(),
136
- occurrence=occurrence,
137
- )
138
- if self.flush_condition_met():
139
- self._notify()
140
- self._outdated = True
141
- self._frame_counter.reset()
142
-
143
- self._notify()
132
+ try:
133
+ while not self.should_stop():
134
+ if (frame := self._read_next_frame()) is not None:
135
+ self._frame_counter.increment()
136
+ occurrence = self._datetime_provider.provide()
137
+
138
+ if self._outdated:
139
+ self._current_video_start_time = occurrence
140
+ self._outdated = False
141
+ self._notify_new_video_start_observers()
142
+
143
+ yield Frame(
144
+ data=convert_frame_to_rgb(frame), # YOLO expects RGB
145
+ frame=self.current_frame_number,
146
+ source=self.rtsp_url,
147
+ output=self.create_output(),
148
+ occurrence=occurrence,
149
+ )
150
+ if self.flush_condition_met():
151
+ self._notify_flush_observers()
152
+ self._outdated = True
153
+ self._frame_counter.reset()
154
+ self._notify_flush_observers()
155
+ except InvalidRtspUrlError as cause:
156
+ logger().error(cause)
144
157
 
145
158
  def _init_video_capture(self, source: str) -> VideoCapture:
159
+ self._wait_for_connection(source)
160
+
146
161
  cap = VideoCapture(source)
147
162
  while not self.should_stop() and not cap.isOpened():
148
- logger().warning(
149
- f"Couldn't open the RTSP stream: {source}. "
150
- f"Trying again in {RETRY_SECONDS}s..."
151
- )
152
- sleep(RETRY_SECONDS)
153
163
  cap.release()
164
+ self._wait_for_connection(source)
154
165
  cap = VideoCapture(source)
155
166
  return cap
156
167
 
168
+ def _wait_for_connection(self, connection: str) -> None:
169
+ while not self.should_stop() and not is_connection_available(connection):
170
+ logger().debug(
171
+ f"Couldn't open the RTSP stream: {connection}. "
172
+ f"Trying again in {RETRY_SECONDS}s..."
173
+ )
174
+ sleep(RETRY_SECONDS)
175
+
157
176
  def _read_next_frame(self) -> ndarray | None:
158
177
  successful, frame = self._video_capture.read()
159
178
  if successful:
179
+ self._consecutive_read_fails = 0
160
180
  return frame
181
+ self._consecutive_read_fails += 1
182
+
183
+ if self._consecutive_read_fails >= self._read_fail_threshold:
184
+ self._try_reconnecting_stream()
185
+
161
186
  logger().debug("Failed to grab frame")
162
187
  return None
163
188
 
189
+ def _try_reconnecting_stream(self) -> None:
190
+ self._video_capture.release()
191
+ self._current_video_capture = None
192
+ if not self.should_stop() and self._current_stream is not None:
193
+ self._current_video_capture = self._init_video_capture(self._current_stream)
194
+
164
195
  def should_stop(self) -> bool:
165
196
  return self._stop_capture
166
197
 
@@ -173,9 +204,9 @@ class RtspInputSource(InputSourceDetect):
173
204
  def flush_condition_met(self) -> bool:
174
205
  return self.current_frame_number % self.flush_buffer_size == 0
175
206
 
176
- def _notify(self) -> None:
177
- frame_width = int(self._video_capture.get(CAP_PROP_FRAME_WIDTH))
178
- frame_height = int(self._video_capture.get(CAP_PROP_FRAME_HEIGHT))
207
+ def _notify_flush_observers(self) -> None:
208
+ frame_width = self._get_width()
209
+ frame_height = self._get_height()
179
210
  frames = (
180
211
  self.flush_buffer_size
181
212
  if self.current_frame_number % self.flush_buffer_size == 0
@@ -183,7 +214,7 @@ class RtspInputSource(InputSourceDetect):
183
214
  )
184
215
  duration = timedelta(seconds=round(frames / self.fps))
185
216
  output = self.create_output()
186
- self._subject.notify(
217
+ self.subject_flush.notify(
187
218
  FlushEvent.create(
188
219
  source=self.rtsp_url,
189
220
  output=output,
@@ -195,6 +226,21 @@ class RtspInputSource(InputSourceDetect):
195
226
  )
196
227
  )
197
228
 
229
+ def _get_width(self) -> int:
230
+ return int(self._video_capture.get(CAP_PROP_FRAME_WIDTH))
231
+
232
+ def _get_height(self) -> int:
233
+ return int(self._video_capture.get(CAP_PROP_FRAME_HEIGHT))
234
+
235
+ def _notify_new_video_start_observers(self) -> None:
236
+ event = NewVideoStartEvent(
237
+ output=self.create_output(),
238
+ width=self._get_width(),
239
+ height=self._get_height(),
240
+ fps=self.fps,
241
+ )
242
+ self.subject_new_video_start.notify(event)
243
+
198
244
  def create_output(self) -> str:
199
245
  output_filename = (
200
246
  f"{self.stream_config.name}_FR{round(self.fps)}"
@@ -205,3 +251,58 @@ class RtspInputSource(InputSourceDetect):
205
251
 
206
252
  def convert_frame_to_rgb(frame: ndarray) -> ndarray:
207
253
  return cvtColor(frame, COLOR_BGR2RGB)
254
+
255
+
256
+ class InvalidRtspUrlError(Exception):
257
+ """Raised when the RTSP URL is invalid."""
258
+
259
+
260
+ def is_connection_available(rtsp_url: str) -> bool:
261
+ """
262
+ Check if RTSP connection is available by sending a DESCRIBE request.
263
+
264
+ Args:
265
+ rtsp_url: The RTSP URL to check
266
+
267
+ Returns:
268
+ bool: True if stream is available, False otherwise
269
+ """
270
+ try:
271
+ parsed = urlparse(rtsp_url)
272
+ if parsed.hostname is None and parsed.port is None:
273
+ raise InvalidRtspUrlError(
274
+ f"Invalid RTSP URL: {rtsp_url}. Missing hostname or port."
275
+ )
276
+
277
+ host = parsed.hostname
278
+ port = parsed.port
279
+
280
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
281
+ sock.settimeout(5)
282
+
283
+ if sock.connect_ex((host, port)) != 0:
284
+ sock.close()
285
+ return False
286
+
287
+ # Send RTSP DESCRIBE request to get stream info
288
+ rtsp_request = (
289
+ f"DESCRIBE {rtsp_url} RTSP/1.0\r\n"
290
+ f"CSeq: 1\r\n"
291
+ f"Accept: application/sdp\r\n\r\n"
292
+ )
293
+ sock.send(rtsp_request.encode())
294
+
295
+ # Read response
296
+ response = sock.recv(4096).decode()
297
+ sock.close()
298
+
299
+ # Check if we got a valid RTSP response with SDP content
300
+ return (
301
+ response.startswith("RTSP/1.0 200 OK")
302
+ and "application/sdp" in response
303
+ and "m=video" in response
304
+ )
305
+ except InvalidRtspUrlError:
306
+ raise
307
+ except Exception:
308
+ return False
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from typing import Generator
5
5
 
6
6
  import av
7
+ from av.container.input import InputContainer
7
8
  from tqdm import tqdm
8
9
 
9
10
  from OTVision.abstraction.observer import Subject
@@ -12,6 +13,7 @@ from OTVision.application.detect.detection_file_save_path_provider import (
12
13
  DetectionFileSavePathProvider,
13
14
  )
14
15
  from OTVision.application.detect.timestamper import Timestamper
16
+ from OTVision.application.event.new_video_start import NewVideoStartEvent
15
17
  from OTVision.application.get_current_config import GetCurrentConfig
16
18
  from OTVision.detect.detected_frame_buffer import FlushEvent
17
19
  from OTVision.detect.plugin_av.rotate_frame import AvVideoFrameRotator
@@ -38,6 +40,9 @@ class VideoSource(InputSourceDetect):
38
40
  and selective frame processing based on configuration parameters.
39
41
 
40
42
  Args:
43
+ subject_flush: (Subject[FlushEvent]): Subject for notifying about flush events.
44
+ subject_new_video_start (Subject[NewVideoStartEvent): Subject for notifying
45
+ about new video start events.
41
46
  get_current_config (GetCurrentConfig): Use case to retrieve current
42
47
  configuration.
43
48
  frame_rotator (AvVideoFrameRotator): Use to rotate video frames.
@@ -56,13 +61,15 @@ class VideoSource(InputSourceDetect):
56
61
 
57
62
  def __init__(
58
63
  self,
59
- subject: Subject[FlushEvent],
64
+ subject_flush: Subject[FlushEvent],
65
+ subject_new_video_start: Subject[NewVideoStartEvent],
60
66
  get_current_config: GetCurrentConfig,
61
67
  frame_rotator: AvVideoFrameRotator,
62
68
  timestamper_factory: TimestamperFactory,
63
69
  save_path_provider: DetectionFileSavePathProvider,
64
70
  ) -> None:
65
- super().__init__(subject)
71
+ self.subject_flush = subject_flush
72
+ self.subject_new_video_start = subject_new_video_start
66
73
  self._frame_rotator = frame_rotator
67
74
  self._get_current_config = get_current_config
68
75
  self._timestamper_factory = timestamper_factory
@@ -97,13 +104,14 @@ class VideoSource(InputSourceDetect):
97
104
  expected_duration=self._current_config.detect.expected_duration,
98
105
  )
99
106
  video_fps = get_fps(video_file)
107
+ self.notify_new_video_start_observers(video_file, video_fps)
100
108
  detect_start = self.__get_detect_start_in_frames(video_fps)
101
109
  detect_end = self.__get_detect_end_in_frames(video_fps)
102
110
  counter = 0
103
111
  try:
104
112
  with av.open(str(video_file.absolute())) as container:
105
113
  container.streams.video[0].thread_type = "AUTO"
106
- side_data = container.streams.video[0].side_data
114
+ side_data = self._extract_side_data(container)
107
115
  for frame_number, frame in enumerate(
108
116
  container.decode(video=0), start=1
109
117
  ):
@@ -129,10 +137,20 @@ class VideoSource(InputSourceDetect):
129
137
  }
130
138
  )
131
139
  counter += 1
132
- self.notify_observers(video_file, video_fps)
140
+ self.notify_flush_event_observers(video_file, video_fps)
133
141
  except Exception as e:
134
142
  log.error(f"Error processing {video_file}", exc_info=e)
135
143
 
144
+ def _extract_side_data(self, container: InputContainer) -> dict:
145
+ try:
146
+ return container.streams.video[0].side_data
147
+ except AttributeError:
148
+ log.warning(
149
+ "No side_data found in video stream. "
150
+ "Existing rotation will not be applied."
151
+ )
152
+ return {}
153
+
136
154
  def __collect_files_to_detect(self) -> list[Path]:
137
155
  filetypes = self._current_config.filetypes.video_filetypes.to_list()
138
156
  video_files = get_files(
@@ -169,7 +187,9 @@ class VideoSource(InputSourceDetect):
169
187
  return False
170
188
  return True
171
189
 
172
- def notify_observers(self, current_video_file: Path, video_fps: float) -> None:
190
+ def notify_flush_event_observers(
191
+ self, current_video_file: Path, video_fps: float
192
+ ) -> None:
173
193
  if expected_duration := self._current_config.detect.expected_duration:
174
194
  duration = expected_duration
175
195
  else:
@@ -180,7 +200,7 @@ class VideoSource(InputSourceDetect):
180
200
  current_video_file, start_time=self._start_time
181
201
  )
182
202
 
183
- self._subject.notify(
203
+ self.subject_flush.notify(
184
204
  FlushEvent.create(
185
205
  source=str(current_video_file),
186
206
  output=str(current_video_file),
@@ -192,6 +212,18 @@ class VideoSource(InputSourceDetect):
192
212
  )
193
213
  )
194
214
 
215
+ def notify_new_video_start_observers(
216
+ self, current_video_file: Path, video_fps: float
217
+ ) -> None:
218
+ width, height = get_video_dimensions(current_video_file)
219
+ event = NewVideoStartEvent(
220
+ output=str(current_video_file),
221
+ width=width,
222
+ height=height,
223
+ fps=video_fps,
224
+ )
225
+ self.subject_new_video_start.notify(event)
226
+
195
227
  def __get_detect_start_in_frames(self, video_fps: float) -> int:
196
228
  detect_start = convert_seconds_to_frames(
197
229
  self._current_config.detect.detect_start, video_fps
OTVision/detect/yolo.py CHANGED
@@ -142,9 +142,17 @@ class YoloDetector(ObjectDetector, Filter[Frame, DetectedFrame]):
142
142
  def detect(
143
143
  self, frames: Generator[Frame, None, None]
144
144
  ) -> Generator[DetectedFrame, None, None]:
145
- for frame in tqdm(frames, desc="Detected frames", unit=" frames"):
145
+ for frame in tqdm(
146
+ frames,
147
+ desc="Detected frames",
148
+ unit=" frames",
149
+ disable=self.disable_tqdm_logging(),
150
+ ):
146
151
  yield self._predict(frame)
147
152
 
153
+ def disable_tqdm_logging(self) -> bool:
154
+ return log.level > logging.INFO
155
+
148
156
  def _predict(self, frame: Frame) -> DetectedFrame:
149
157
  if frame[FrameKeys.data] is None:
150
158
  return self._create_empty_detection(frame)
OTVision/domain/cli.py CHANGED
@@ -3,6 +3,12 @@ from dataclasses import dataclass
3
3
  from datetime import datetime, timedelta
4
4
  from pathlib import Path
5
5
 
6
+ from OTVision.plugin.ffmpeg_video_writer import (
7
+ ConstantRateFactor,
8
+ EncodingSpeed,
9
+ VideoCodec,
10
+ )
11
+
6
12
 
7
13
  class CliArgs(ABC):
8
14
  @abstractmethod
@@ -32,6 +38,10 @@ class DetectCliArgs(CliArgs):
32
38
  start_time: datetime | None = None
33
39
  detect_start: int | None = None
34
40
  detect_end: int | None = None
41
+ write_video: bool | None = None
42
+ video_codec: VideoCodec | None = None
43
+ encoding_speed: EncodingSpeed | None = None
44
+ crf: ConstantRateFactor | None = None
35
45
 
36
46
  def get_config_file(self) -> Path | None:
37
47
  return self.config_file
@@ -1,12 +1,10 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from typing import Generator
3
3
 
4
- from OTVision.abstraction.observer import Observable
5
- from OTVision.detect.detected_frame_buffer import FlushEvent
6
4
  from OTVision.domain.frame import Frame
7
5
 
8
6
 
9
- class InputSourceDetect(Observable[FlushEvent], ABC):
7
+ class InputSourceDetect(ABC):
10
8
  """Interface for input sources that generate frames and notify about flush events.
11
9
 
12
10
  This class combines the Observable pattern for flush events with frame generation
OTVision/domain/time.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
- from datetime import datetime, timezone
2
+ from datetime import datetime
3
3
 
4
4
 
5
5
  class DatetimeProvider(ABC):
@@ -10,4 +10,4 @@ class DatetimeProvider(ABC):
10
10
 
11
11
  class CurrentDatetimeProvider(DatetimeProvider):
12
12
  def provide(self) -> datetime:
13
- return datetime.now(tz=timezone.utc)
13
+ return datetime.now()
@@ -0,0 +1,30 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from numpy import ndarray
4
+
5
+ from OTVision.abstraction.pipes_and_filter import Filter
6
+ from OTVision.application.event.new_video_start import NewVideoStartEvent
7
+ from OTVision.detect.detected_frame_buffer import FlushEvent
8
+ from OTVision.domain.frame import Frame
9
+
10
+
11
+ class VideoWriter(Filter[Frame, Frame], ABC):
12
+ @abstractmethod
13
+ def write(self, image: ndarray) -> None:
14
+ raise NotImplementedError
15
+
16
+ @abstractmethod
17
+ def open(self, output: str, width: int, height: int, fps: float) -> None:
18
+ raise NotImplementedError
19
+
20
+ @abstractmethod
21
+ def close(self) -> None:
22
+ raise NotImplementedError
23
+
24
+ @abstractmethod
25
+ def notify_on_flush_event(self, event: FlushEvent) -> None:
26
+ raise NotImplementedError
27
+
28
+ @abstractmethod
29
+ def notify_on_new_video_start(self, event: NewVideoStartEvent) -> None:
30
+ raise NotImplementedError