OTVision 0.5.3__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.
Files changed (50) hide show
  1. OTVision/__init__.py +30 -0
  2. OTVision/application/__init__.py +0 -0
  3. OTVision/application/configure_logger.py +23 -0
  4. OTVision/application/detect/__init__.py +0 -0
  5. OTVision/application/detect/get_detect_cli_args.py +9 -0
  6. OTVision/application/detect/update_detect_config_with_cli_args.py +95 -0
  7. OTVision/application/get_config.py +25 -0
  8. OTVision/config.py +754 -0
  9. OTVision/convert/__init__.py +0 -0
  10. OTVision/convert/convert.py +318 -0
  11. OTVision/dataformat.py +70 -0
  12. OTVision/detect/__init__.py +0 -0
  13. OTVision/detect/builder.py +48 -0
  14. OTVision/detect/cli.py +166 -0
  15. OTVision/detect/detect.py +296 -0
  16. OTVision/detect/otdet.py +103 -0
  17. OTVision/detect/plugin_av/__init__.py +0 -0
  18. OTVision/detect/plugin_av/rotate_frame.py +37 -0
  19. OTVision/detect/yolo.py +277 -0
  20. OTVision/domain/__init__.py +0 -0
  21. OTVision/domain/cli.py +42 -0
  22. OTVision/helpers/__init__.py +0 -0
  23. OTVision/helpers/date.py +26 -0
  24. OTVision/helpers/files.py +538 -0
  25. OTVision/helpers/formats.py +139 -0
  26. OTVision/helpers/log.py +131 -0
  27. OTVision/helpers/machine.py +71 -0
  28. OTVision/helpers/video.py +54 -0
  29. OTVision/track/__init__.py +0 -0
  30. OTVision/track/iou.py +282 -0
  31. OTVision/track/iou_util.py +140 -0
  32. OTVision/track/preprocess.py +451 -0
  33. OTVision/track/track.py +422 -0
  34. OTVision/transform/__init__.py +0 -0
  35. OTVision/transform/get_homography.py +156 -0
  36. OTVision/transform/reference_points_picker.py +462 -0
  37. OTVision/transform/transform.py +352 -0
  38. OTVision/version.py +13 -0
  39. OTVision/view/__init__.py +0 -0
  40. OTVision/view/helpers/OTC.ico +0 -0
  41. OTVision/view/view.py +90 -0
  42. OTVision/view/view_convert.py +128 -0
  43. OTVision/view/view_detect.py +146 -0
  44. OTVision/view/view_helpers.py +417 -0
  45. OTVision/view/view_track.py +131 -0
  46. OTVision/view/view_transform.py +140 -0
  47. otvision-0.5.3.dist-info/METADATA +47 -0
  48. otvision-0.5.3.dist-info/RECORD +50 -0
  49. otvision-0.5.3.dist-info/WHEEL +4 -0
  50. otvision-0.5.3.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,296 @@
1
+ """
2
+ OTVision main module to detect objects in single or multiple images or videos.
3
+ """
4
+
5
+ # Copyright (C) 2022 OpenTrafficCam Contributors
6
+ # <https://github.com/OpenTrafficCam
7
+ # <team@opentrafficcam.org>
8
+ #
9
+ # This program is free software: you can redistribute it and/or modify
10
+ # it under the terms of the GNU General Public License as published by
11
+ # the Free Software Foundation, either version 3 of the License, or
12
+ # (at your option) any later version.
13
+ #
14
+ # This program is distributed in the hope that it will be useful,
15
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ # GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License
20
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
+
22
+ import logging
23
+ import re
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
+
27
+ from tqdm import tqdm
28
+
29
+ from OTVision.config import Config
30
+ from OTVision.dataformat import DATA, LENGTH, METADATA, RECORDED_START_DATE, VIDEO
31
+ from OTVision.detect.otdet import OtdetBuilder, OtdetBuilderConfig
32
+ from OTVision.detect.yolo import create_model
33
+ from OTVision.helpers.date import parse_date_string_to_utc_datime
34
+ from OTVision.helpers.files import (
35
+ FILE_NAME_PATTERN,
36
+ START_DATE,
37
+ InproperFormattedFilename,
38
+ get_files,
39
+ write_json,
40
+ )
41
+ from OTVision.helpers.log import LOGGER_NAME
42
+ from OTVision.helpers.video import get_duration, get_fps, get_video_dimensions
43
+ from OTVision.track.preprocess import OCCURRENCE
44
+
45
+ log = logging.getLogger(LOGGER_NAME)
46
+
47
+
48
+ class OTVisionDetect:
49
+ @property
50
+ def config(self) -> Config:
51
+ if self._config is None:
52
+ raise ValueError("Config is missing!")
53
+ return self._config
54
+
55
+ def __init__(self, otdet_builder: OtdetBuilder) -> None:
56
+ self._config: Config | None = None
57
+ self._otdet_builder = otdet_builder
58
+
59
+ def update_config(self, config: Config) -> None:
60
+ self._config = config
61
+
62
+ def start(self) -> None:
63
+ """Starts the detection of objects in multiple videos and/or images.
64
+
65
+ Writes detections to one file per video/object.
66
+
67
+ """
68
+ filetypes = self.config.filetypes.video_filetypes.to_list()
69
+ video_files = get_files(paths=self.config.detect.paths, filetypes=filetypes)
70
+
71
+ start_msg = f"Start detection of {len(video_files)} video files"
72
+ log.info(start_msg)
73
+ print(start_msg)
74
+
75
+ if not video_files:
76
+ log.warning(f"No videos of type '{filetypes}' found to detect!")
77
+ return
78
+
79
+ model = create_model(
80
+ weights=self.config.detect.yolo_config.weights,
81
+ confidence=self.config.detect.yolo_config.conf,
82
+ iou=self.config.detect.yolo_config.iou,
83
+ img_size=self.config.detect.yolo_config.img_size,
84
+ half_precision=self.config.detect.half_precision,
85
+ normalized=self.config.detect.yolo_config.normalized,
86
+ )
87
+ for video_file in tqdm(video_files, desc="Detected video files", unit=" files"):
88
+ detections_file = derive_filename(
89
+ video_file=video_file,
90
+ detect_start=self.config.detect.detect_start,
91
+ detect_end=self.config.detect.detect_end,
92
+ detect_suffix=self.config.filetypes.detect,
93
+ )
94
+
95
+ if not self.config.detect.overwrite and detections_file.is_file():
96
+ log.warning(
97
+ f"{detections_file} already exists. To overwrite, set overwrite "
98
+ "to True"
99
+ )
100
+ continue
101
+
102
+ log.info(f"Detect {video_file}")
103
+
104
+ video_fps = get_fps(video_file)
105
+ detect_start_in_frames = convert_seconds_to_frames(
106
+ self.config.detect.detect_start, video_fps
107
+ )
108
+ detect_end_in_frames = convert_seconds_to_frames(
109
+ self.config.detect.detect_end, video_fps
110
+ )
111
+ detections = model.detect(
112
+ file=video_file,
113
+ detect_start=detect_start_in_frames,
114
+ detect_end=detect_end_in_frames,
115
+ )
116
+
117
+ video_width, video_height = get_video_dimensions(video_file)
118
+ actual_duration = get_duration(video_file)
119
+ actual_frames = len(detections)
120
+ if (expected_duration := self.config.detect.expected_duration) is not None:
121
+ actual_fps = actual_frames / expected_duration.total_seconds()
122
+ else:
123
+ actual_fps = actual_frames / actual_duration.total_seconds()
124
+ otdet = self._otdet_builder.add_config(
125
+ OtdetBuilderConfig(
126
+ conf=model.confidence,
127
+ iou=model.iou,
128
+ video=video_file,
129
+ video_width=video_width,
130
+ video_height=video_height,
131
+ expected_duration=expected_duration,
132
+ recorded_fps=video_fps,
133
+ actual_fps=actual_fps,
134
+ actual_frames=actual_frames,
135
+ detection_img_size=model.img_size,
136
+ normalized=model.normalized,
137
+ detection_model=model.weights,
138
+ half_precision=model.half_precision,
139
+ chunksize=1,
140
+ classifications=model.classifications,
141
+ )
142
+ ).build(detections)
143
+
144
+ stamped_detections = add_timestamps(otdet, video_file, expected_duration)
145
+ write_json(
146
+ stamped_detections,
147
+ file=detections_file,
148
+ filetype=self.config.filetypes.detect,
149
+ overwrite=self.config.detect.overwrite,
150
+ )
151
+
152
+ log.info(f"Successfully detected and wrote {detections_file}")
153
+
154
+ finished_msg = "Finished detection"
155
+ log.info(finished_msg)
156
+ print(finished_msg)
157
+
158
+
159
+ def derive_filename(
160
+ video_file: Path,
161
+ detect_suffix: str,
162
+ detect_start: int | None = None,
163
+ detect_end: int | None = None,
164
+ ) -> Path:
165
+ """
166
+ Generates a filename for detection files by appending specified start and end
167
+ markers and a suffix to the stem of the input video file.
168
+
169
+ Args:
170
+ video_file (Path): The input video file whose filename is to be modified.
171
+ detect_start (int | None): The starting marker to append to the filename.
172
+ If None, no starting marker will be appended.
173
+ detect_end (int | None): The ending marker to append to the filename. If None,
174
+ no ending marker will be appended.
175
+ detect_suffix (str): The file suffix to apply to the derived filename.
176
+
177
+ Returns:
178
+ Path: The modified video file path with the updated stem and suffix applied.
179
+ """
180
+ cutout = ""
181
+ if detect_start is not None:
182
+ cutout += f"_start_{detect_start}"
183
+ if detect_end is not None:
184
+ cutout += f"_end_{detect_end}"
185
+ new_stem = f"{video_file.stem}{cutout}"
186
+ return video_file.with_stem(new_stem).with_suffix(detect_suffix)
187
+
188
+
189
+ def convert_seconds_to_frames(seconds: int | None, fps: float) -> int | None:
190
+ if seconds is None:
191
+ return None
192
+ return round(seconds * fps)
193
+
194
+
195
+ class FormatNotSupportedError(Exception):
196
+ pass
197
+
198
+
199
+ def add_timestamps(
200
+ detections: dict, video_file: Path, expected_duration: timedelta | None
201
+ ) -> dict:
202
+ return Timestamper().stamp(detections, video_file, expected_duration)
203
+
204
+
205
+ class Timestamper:
206
+ def stamp(
207
+ self, detections: dict, video_file: Path, expected_duration: timedelta | None
208
+ ) -> dict:
209
+ """This method adds timestamps when the frame occurred in real time to each
210
+ frame.
211
+
212
+ Args:
213
+ detections (dict): dictionary containing all frames
214
+ video_file (Path): path to video file
215
+ expected_duration (timedelta | None): expected duration of the video used to
216
+ calculate the number of actual frames per second
217
+
218
+ Returns:
219
+ dict: input dictionary with additional occurrence per frame
220
+ """
221
+ start_time = self._get_start_time_from(video_file)
222
+ actual_duration = get_duration(video_file)
223
+ if expected_duration:
224
+ time_per_frame = self._get_time_per_frame(detections, expected_duration)
225
+ else:
226
+ time_per_frame = self._get_time_per_frame(detections, actual_duration)
227
+ self._update_metadata(detections, start_time, actual_duration)
228
+ return self._stamp(detections, start_time, time_per_frame)
229
+
230
+ @staticmethod
231
+ def _get_start_time_from(video_file: Path) -> datetime:
232
+ """Parse the given filename and retrieve the start date of the video.
233
+
234
+ Args:
235
+ video_file (Path): path to video file
236
+
237
+ Raises:
238
+ InproperFormattedFilename: if the filename is not formatted as expected, an
239
+ exception will be raised
240
+
241
+ Returns:
242
+ datetime: start date of the video
243
+ """
244
+ match = re.search(
245
+ FILE_NAME_PATTERN,
246
+ video_file.name,
247
+ )
248
+ if match:
249
+ start_date: str = match.group(START_DATE)
250
+ return parse_date_string_to_utc_datime(
251
+ start_date, "%Y-%m-%d_%H-%M-%S"
252
+ ).replace(tzinfo=timezone.utc)
253
+
254
+ raise InproperFormattedFilename(f"Could not parse {video_file.name}.")
255
+
256
+ @staticmethod
257
+ def _get_time_per_frame(detections: dict, duration: timedelta) -> timedelta:
258
+ """Calculates the duration for each frame. This is done using the total
259
+ duration of the video and the number of frames.
260
+
261
+ Args:
262
+ detections (dict): dictionary containing all frames
263
+ video_file (Path): path to video file
264
+
265
+ Returns:
266
+ timedelta: duration per frame
267
+ """
268
+ number_of_frames = len(detections[DATA].keys())
269
+ return duration / number_of_frames
270
+
271
+ @staticmethod
272
+ def _update_metadata(
273
+ detections: dict, start_time: datetime, duration: timedelta
274
+ ) -> dict:
275
+ detections[METADATA][VIDEO][RECORDED_START_DATE] = start_time.timestamp()
276
+ detections[METADATA][VIDEO][LENGTH] = str(duration)
277
+ return detections
278
+
279
+ def _stamp(
280
+ self, detections: dict, start_date: datetime, time_per_frame: timedelta
281
+ ) -> dict:
282
+ """Add a timestamp (occurrence in real time) to each frame.
283
+
284
+ Args:
285
+ detections (dict): dictionary containing all frames
286
+ start_date (datetime): start date of the video recording
287
+ time_per_frame (timedelta): duration per frame
288
+
289
+ Returns:
290
+ dict: dictionary containing all frames with their occurrence in real time
291
+ """
292
+ data: dict = detections[DATA]
293
+ for key, value in data.items():
294
+ occurrence = start_date + (int(key) - 1) * time_per_frame
295
+ value[OCCURRENCE] = occurrence.timestamp()
296
+ return detections
@@ -0,0 +1,103 @@
1
+ from dataclasses import dataclass
2
+ from datetime import timedelta
3
+ from pathlib import Path
4
+ from typing import Self
5
+
6
+ from OTVision import dataformat, version
7
+ from OTVision.track.preprocess import Detection
8
+
9
+
10
+ @dataclass
11
+ class OtdetBuilderConfig:
12
+ conf: float
13
+ iou: float
14
+ video: Path
15
+ video_width: int
16
+ video_height: int
17
+ expected_duration: timedelta | None
18
+ recorded_fps: float
19
+ actual_fps: float
20
+ actual_frames: int
21
+ detection_img_size: int
22
+ normalized: bool
23
+ detection_model: str | Path
24
+ half_precision: bool
25
+ chunksize: int
26
+ classifications: dict[int, str]
27
+
28
+
29
+ class OtdetBuilderError(Exception):
30
+ pass
31
+
32
+
33
+ class OtdetBuilder:
34
+ @property
35
+ def config(self) -> OtdetBuilderConfig:
36
+ if self._config is None:
37
+ raise OtdetBuilderError("Otdet builder config is not set")
38
+ return self._config
39
+
40
+ def __init__(self) -> None:
41
+ self._config: OtdetBuilderConfig | None = None
42
+
43
+ def add_config(self, config: OtdetBuilderConfig) -> Self:
44
+ self._config = config
45
+ return self
46
+
47
+ def reset(self) -> Self:
48
+ self._config = None
49
+ return self
50
+
51
+ def build(self, detections: list[list[Detection]]) -> dict:
52
+ result = {
53
+ dataformat.METADATA: self._build_metadata(),
54
+ dataformat.DATA: self._build_data(detections),
55
+ }
56
+ self.reset()
57
+ return result
58
+
59
+ def _build_metadata(self) -> dict:
60
+ return {
61
+ dataformat.OTDET_VERSION: version.otdet_version(),
62
+ dataformat.VIDEO: self._build_video_config(),
63
+ dataformat.DETECTION: self._build_detection_config(),
64
+ }
65
+
66
+ def _build_data(self, frames: list[list[Detection]]) -> dict:
67
+ data = {}
68
+ for frame, detections in enumerate(frames, start=1):
69
+ converted_detections = [detection.to_otdet() for detection in detections]
70
+ data[str(frame)] = {dataformat.DETECTIONS: converted_detections}
71
+ return data
72
+
73
+ def _build_video_config(self) -> dict:
74
+ video_config = {
75
+ dataformat.FILENAME: str(self.config.video.stem),
76
+ dataformat.FILETYPE: str(self.config.video.suffix),
77
+ dataformat.WIDTH: self.config.video_width,
78
+ dataformat.HEIGHT: self.config.video_height,
79
+ dataformat.RECORDED_FPS: self.config.recorded_fps,
80
+ dataformat.ACTUAL_FPS: self.config.actual_fps,
81
+ dataformat.NUMBER_OF_FRAMES: self.config.actual_frames,
82
+ }
83
+ if self.config.expected_duration is not None:
84
+ video_config[dataformat.EXPECTED_DURATION] = int(
85
+ self.config.expected_duration.total_seconds()
86
+ )
87
+ return video_config
88
+
89
+ def _build_detection_config(self) -> dict:
90
+ return {
91
+ dataformat.OTVISION_VERSION: version.otvision_version(),
92
+ dataformat.MODEL: {
93
+ dataformat.NAME: "YOLOv8",
94
+ dataformat.WEIGHTS: str(self.config.detection_model),
95
+ dataformat.IOU_THRESHOLD: self.config.iou,
96
+ dataformat.IMAGE_SIZE: self.config.detection_img_size,
97
+ dataformat.MAX_CONFIDENCE: self.config.conf,
98
+ dataformat.HALF_PRECISION: self.config.half_precision,
99
+ dataformat.CLASSES: self.config.classifications,
100
+ },
101
+ dataformat.CHUNKSIZE: self.config.chunksize,
102
+ dataformat.NORMALIZED_BBOX: self.config.normalized,
103
+ }
File without changes
@@ -0,0 +1,37 @@
1
+ from av import VideoFrame
2
+ from numpy import ndarray, rot90
3
+
4
+ DISPLAYMATRIX = "DISPLAYMATRIX"
5
+
6
+
7
+ class AvVideoFrameRotator:
8
+ def __init__(self, img_format: str = "rgb24"):
9
+ self._img_format = img_format
10
+
11
+ def rotate(self, frame: VideoFrame, side_data: dict) -> ndarray:
12
+ array = frame.to_ndarray(format=self._img_format)
13
+ rotated_image = rotate(array, side_data)
14
+ return rotated_image
15
+
16
+
17
+ def rotate(array: ndarray, side_data: dict) -> ndarray:
18
+ """
19
+ Rotate a numpy array using the DISPLAYMATRIX rotation angle defined in side_data.
20
+
21
+ Args:
22
+ array: to rotate
23
+ side_data: metadata dictionary to read the angle from
24
+
25
+ Returns: rotated array
26
+
27
+ """
28
+ if DISPLAYMATRIX in side_data:
29
+ angle = side_data[DISPLAYMATRIX]
30
+ if angle % 90 != 0:
31
+ raise ValueError(
32
+ f"Rotation angle must be multiple of 90 degrees, but is {angle}"
33
+ )
34
+ rotation = angle / 90
35
+ rotated_image = rot90(array, rotation)
36
+ return rotated_image
37
+ return array