OTVision 0.6.7__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,7 @@ 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
30
33
  DEFAULT_READ_FAIL_THRESHOLD = 5
31
34
 
32
35
 
@@ -79,13 +82,16 @@ class RtspInputSource(InputSourceDetect):
79
82
 
80
83
  def __init__(
81
84
  self,
82
- subject: Subject[FlushEvent],
85
+ subject_flush: Subject[FlushEvent],
86
+ subject_new_video_start: Subject[NewVideoStartEvent],
83
87
  datetime_provider: DatetimeProvider,
84
88
  frame_counter: Counter,
85
89
  get_current_config: GetCurrentConfig,
86
90
  read_fail_threshold: int = DEFAULT_READ_FAIL_THRESHOLD,
87
91
  ) -> None:
88
- super().__init__(subject)
92
+
93
+ self.subject_flush = subject_flush
94
+ self.subject_new_video_start = subject_new_video_start
89
95
  self._datetime_provider = datetime_provider
90
96
  self._stop_capture = False
91
97
  self._frame_counter = frame_counter
@@ -123,41 +129,50 @@ class RtspInputSource(InputSourceDetect):
123
129
  def produce(self) -> Generator[Frame, None, None]:
124
130
  self._stream_start_time = self._datetime_provider.provide()
125
131
  self._current_video_start_time = self._stream_start_time
126
- while not self.should_stop():
127
- if (frame := self._read_next_frame()) is not None:
128
- self._frame_counter.increment()
129
- occurrence = self._datetime_provider.provide()
130
-
131
- if self._outdated:
132
- self._current_video_start_time = occurrence
133
- self._outdated = False
134
-
135
- yield Frame(
136
- data=convert_frame_to_rgb(frame), # YOLO expects RGB
137
- frame=self.current_frame_number,
138
- source=self.rtsp_url,
139
- output=self.create_output(),
140
- occurrence=occurrence,
141
- )
142
- if self.flush_condition_met():
143
- self._notify()
144
- self._outdated = True
145
- self._frame_counter.reset()
146
-
147
- 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)
148
157
 
149
158
  def _init_video_capture(self, source: str) -> VideoCapture:
159
+ self._wait_for_connection(source)
160
+
150
161
  cap = VideoCapture(source)
151
162
  while not self.should_stop() and not cap.isOpened():
152
- logger().warning(
153
- f"Couldn't open the RTSP stream: {source}. "
154
- f"Trying again in {RETRY_SECONDS}s..."
155
- )
156
- sleep(RETRY_SECONDS)
157
163
  cap.release()
164
+ self._wait_for_connection(source)
158
165
  cap = VideoCapture(source)
159
166
  return cap
160
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
+
161
176
  def _read_next_frame(self) -> ndarray | None:
162
177
  successful, frame = self._video_capture.read()
163
178
  if successful:
@@ -189,9 +204,9 @@ class RtspInputSource(InputSourceDetect):
189
204
  def flush_condition_met(self) -> bool:
190
205
  return self.current_frame_number % self.flush_buffer_size == 0
191
206
 
192
- def _notify(self) -> None:
193
- frame_width = int(self._video_capture.get(CAP_PROP_FRAME_WIDTH))
194
- 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()
195
210
  frames = (
196
211
  self.flush_buffer_size
197
212
  if self.current_frame_number % self.flush_buffer_size == 0
@@ -199,7 +214,7 @@ class RtspInputSource(InputSourceDetect):
199
214
  )
200
215
  duration = timedelta(seconds=round(frames / self.fps))
201
216
  output = self.create_output()
202
- self._subject.notify(
217
+ self.subject_flush.notify(
203
218
  FlushEvent.create(
204
219
  source=self.rtsp_url,
205
220
  output=output,
@@ -211,6 +226,21 @@ class RtspInputSource(InputSourceDetect):
211
226
  )
212
227
  )
213
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
+
214
244
  def create_output(self) -> str:
215
245
  output_filename = (
216
246
  f"{self.stream_config.name}_FR{round(self.fps)}"
@@ -221,3 +251,58 @@ class RtspInputSource(InputSourceDetect):
221
251
 
222
252
  def convert_frame_to_rgb(frame: ndarray) -> ndarray:
223
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