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.
- OTVision/__init__.py +30 -0
- OTVision/application/__init__.py +0 -0
- OTVision/application/configure_logger.py +23 -0
- OTVision/application/detect/__init__.py +0 -0
- OTVision/application/detect/get_detect_cli_args.py +9 -0
- OTVision/application/detect/update_detect_config_with_cli_args.py +95 -0
- OTVision/application/get_config.py +25 -0
- OTVision/config.py +754 -0
- OTVision/convert/__init__.py +0 -0
- OTVision/convert/convert.py +318 -0
- OTVision/dataformat.py +70 -0
- OTVision/detect/__init__.py +0 -0
- OTVision/detect/builder.py +48 -0
- OTVision/detect/cli.py +166 -0
- OTVision/detect/detect.py +296 -0
- OTVision/detect/otdet.py +103 -0
- OTVision/detect/plugin_av/__init__.py +0 -0
- OTVision/detect/plugin_av/rotate_frame.py +37 -0
- OTVision/detect/yolo.py +277 -0
- OTVision/domain/__init__.py +0 -0
- OTVision/domain/cli.py +42 -0
- OTVision/helpers/__init__.py +0 -0
- OTVision/helpers/date.py +26 -0
- OTVision/helpers/files.py +538 -0
- OTVision/helpers/formats.py +139 -0
- OTVision/helpers/log.py +131 -0
- OTVision/helpers/machine.py +71 -0
- OTVision/helpers/video.py +54 -0
- OTVision/track/__init__.py +0 -0
- OTVision/track/iou.py +282 -0
- OTVision/track/iou_util.py +140 -0
- OTVision/track/preprocess.py +451 -0
- OTVision/track/track.py +422 -0
- OTVision/transform/__init__.py +0 -0
- OTVision/transform/get_homography.py +156 -0
- OTVision/transform/reference_points_picker.py +462 -0
- OTVision/transform/transform.py +352 -0
- OTVision/version.py +13 -0
- OTVision/view/__init__.py +0 -0
- OTVision/view/helpers/OTC.ico +0 -0
- OTVision/view/view.py +90 -0
- OTVision/view/view_convert.py +128 -0
- OTVision/view/view_detect.py +146 -0
- OTVision/view/view_helpers.py +417 -0
- OTVision/view/view_track.py +131 -0
- OTVision/view/view_transform.py +140 -0
- otvision-0.5.3.dist-info/METADATA +47 -0
- otvision-0.5.3.dist-info/RECORD +50 -0
- otvision-0.5.3.dist-info/WHEEL +4 -0
- 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
|
OTVision/detect/otdet.py
ADDED
|
@@ -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
|