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,451 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from OTVision import dataformat, version
8
+ from OTVision.dataformat import (
9
+ CLASS,
10
+ CONFIDENCE,
11
+ DATA,
12
+ DATE_FORMAT,
13
+ DETECTIONS,
14
+ EXPECTED_DURATION,
15
+ FILENAME,
16
+ FRAME,
17
+ INPUT_FILE_PATH,
18
+ INTERPOLATED_DETECTION,
19
+ OCCURRENCE,
20
+ OTTRACK_VERSION,
21
+ RECORDED_START_DATE,
22
+ TRACK_ID,
23
+ VIDEO,
24
+ H,
25
+ W,
26
+ X,
27
+ Y,
28
+ )
29
+ from OTVision.helpers.date import (
30
+ parse_date_string_to_utc_datime,
31
+ parse_timestamp_string_to_utc_datetime,
32
+ )
33
+ from OTVision.helpers.files import (
34
+ FULL_FILE_NAME_PATTERN,
35
+ HOSTNAME,
36
+ InproperFormattedFilename,
37
+ read_json,
38
+ read_json_bz2_metadata,
39
+ )
40
+
41
+ MISSING_START_DATE = datetime(1900, 1, 1)
42
+ MISSING_EXPECTED_DURATION = timedelta(minutes=15)
43
+
44
+
45
+ def parse_datetime(date: str | float) -> datetime:
46
+ """Parse a date string or timestamp to a datetime with UTC as timezone.
47
+
48
+ Args:
49
+ date (str | float): the date to parse
50
+
51
+ Returns:
52
+ datetime: the parsed datetime object with UTC set as timezone
53
+ """
54
+ if isinstance(date, str) and ("-" in date):
55
+ return parse_date_string_to_utc_datime(date, DATE_FORMAT)
56
+ return parse_timestamp_string_to_utc_datetime(date)
57
+
58
+
59
+ @dataclass(frozen=True, repr=True)
60
+ class Detection:
61
+ """
62
+ Data class which contains information for a single detection.
63
+ """
64
+
65
+ label: str
66
+ conf: float
67
+ x: float
68
+ y: float
69
+ w: float
70
+ h: float
71
+
72
+ def to_dict(self, frame: int, occurrence: datetime, input_file_path: str) -> dict:
73
+ return {
74
+ CLASS: self.label,
75
+ CONFIDENCE: self.conf,
76
+ X: self.x,
77
+ Y: self.y,
78
+ W: self.w,
79
+ H: self.h,
80
+ FRAME: frame,
81
+ OCCURRENCE: occurrence.timestamp(),
82
+ INPUT_FILE_PATH: input_file_path,
83
+ INTERPOLATED_DETECTION: False,
84
+ }
85
+
86
+ def to_otdet(self) -> dict:
87
+ return {
88
+ CLASS: self.label,
89
+ CONFIDENCE: self.conf,
90
+ X: self.x,
91
+ Y: self.y,
92
+ W: self.w,
93
+ H: self.h,
94
+ }
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class Frame:
99
+ frame: int
100
+ occurrence: datetime
101
+ input_file_path: Path
102
+ detections: list[Detection]
103
+
104
+ def to_dict(self) -> dict:
105
+ return {
106
+ FRAME: self.frame,
107
+ OCCURRENCE: self.occurrence.timestamp(),
108
+ INPUT_FILE_PATH: self.input_file_path.as_posix(),
109
+ DETECTIONS: [
110
+ detection.to_dict(
111
+ self.frame, self.occurrence, self.input_file_path.as_posix()
112
+ )
113
+ for detection in self.detections
114
+ ],
115
+ }
116
+
117
+ def derive_frame_number(self, new_frame_number: int) -> "Frame":
118
+ return Frame(
119
+ new_frame_number, self.occurrence, self.input_file_path, self.detections
120
+ )
121
+
122
+ def get_output_file(self, with_suffix: str) -> Path:
123
+ return self.input_file_path.with_suffix(with_suffix)
124
+
125
+
126
+ class FrameGroup:
127
+ def __init__(
128
+ self,
129
+ start_date: datetime,
130
+ end_date: datetime,
131
+ hostname: str,
132
+ file: Path | None,
133
+ metadata: dict | None,
134
+ ) -> None:
135
+ self._files_metadata: dict[str, dict] = dict()
136
+ self._files: list[Path] = []
137
+ if (file is not None) and (metadata is not None):
138
+ self._files_metadata[file.as_posix()] = metadata
139
+ self._files.append(file)
140
+
141
+ self.hostname = hostname
142
+ self._start_date = start_date
143
+ self._end_date = end_date
144
+
145
+ @property
146
+ def files(self) -> list[Path]:
147
+ return self._files
148
+
149
+ def metadata_for(self, file: Path | str) -> dict:
150
+ if isinstance(file, str):
151
+ return self._files_metadata[file]
152
+ else:
153
+ return self._files_metadata[file.as_posix()]
154
+
155
+ def start_date(self) -> datetime:
156
+ return self._start_date
157
+
158
+ def end_date(self) -> datetime:
159
+ return self._end_date
160
+
161
+ def merge(self, other: "FrameGroup") -> "FrameGroup":
162
+ if self.start_date() < other.start_date():
163
+ return self._merge(self, other)
164
+ else:
165
+ return self._merge(other, self)
166
+
167
+ def _merge(self, first: "FrameGroup", second: "FrameGroup") -> "FrameGroup":
168
+ if first.hostname != second.hostname:
169
+ raise ValueError("Hostname of FrameGroups does not match")
170
+ merged = FrameGroup(
171
+ start_date=first._start_date,
172
+ end_date=second._end_date,
173
+ hostname=self.hostname,
174
+ file=None,
175
+ metadata=None,
176
+ )
177
+
178
+ merged._files_metadata.update(first._files_metadata)
179
+ merged._files_metadata.update(second._files_metadata)
180
+ merged._files += first.files
181
+ merged._files += second.files
182
+
183
+ return merged
184
+
185
+ def __repr__(self) -> str:
186
+ return self.__str__()
187
+
188
+ def __str__(self) -> str:
189
+ return f"{self._start_date} - {self._end_date}"
190
+
191
+ def update_metadata(self, tracker_data: dict[str, dict]) -> None:
192
+ for filepath in self.files:
193
+ metadata = self.metadata_for(filepath)
194
+ metadata[OTTRACK_VERSION] = version.ottrack_version()
195
+ metadata[dataformat.TRACKING] = {
196
+ dataformat.OTVISION_VERSION: version.otvision_version(),
197
+ dataformat.FIRST_TRACKED_VIDEO_START: self.start_date().timestamp(),
198
+ dataformat.LAST_TRACKED_VIDEO_END: self.end_date().timestamp(),
199
+ dataformat.TRACKER: tracker_data,
200
+ }
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class FrameChunk:
205
+ file: Path
206
+ frames: list[Frame]
207
+
208
+ def start_date(self) -> datetime:
209
+ return self.frames[0].occurrence
210
+
211
+ def end_date(self) -> datetime:
212
+ return self.frames[-1].occurrence
213
+
214
+ def last_frame_id(self) -> int:
215
+ return self.frames[-1].frame
216
+
217
+ def get_existing_output_files(self, with_suffix: str) -> list[Path]:
218
+ output_files = set(
219
+ [frame.get_output_file(with_suffix=with_suffix) for frame in self.frames]
220
+ )
221
+ existing_files = [file for file in output_files if file.is_file()]
222
+ return existing_files
223
+
224
+ def to_dict(self) -> dict:
225
+ return {
226
+ DATA: {frame.frame: frame.to_dict() for frame in self.frames},
227
+ }
228
+
229
+ def __repr__(self) -> str:
230
+ return self.__str__()
231
+
232
+ def __str__(self) -> str:
233
+ return str(self.file)
234
+
235
+
236
+ class FrameIndexer:
237
+ def reindex(self, frames: dict[str, dict], frame_offset: int) -> list[dict]:
238
+ detections = []
239
+ for track in frames.values():
240
+ for detection in track.values():
241
+ # Take into account that consecutive tracks over more than one
242
+ # video must have their frame reset to one when splitting.
243
+ # This is done by taking the frame_offset into account.
244
+ detection[FRAME] = detection[FRAME] - frame_offset
245
+ detections.append(detection)
246
+
247
+ if len(detections) == 0:
248
+ return []
249
+
250
+ assert len({detection[INPUT_FILE_PATH] for detection in detections}) == 1
251
+
252
+ detections.sort(
253
+ key=lambda detection: (
254
+ detection[INPUT_FILE_PATH],
255
+ detection[FRAME],
256
+ detection[TRACK_ID],
257
+ )
258
+ )
259
+
260
+ return detections
261
+
262
+
263
+ class DetectionParser:
264
+ def convert(self, data_detections: list[dict[str, str]]) -> list[Detection]:
265
+ detections: list[Detection] = []
266
+ for detection in data_detections:
267
+ detected_item = Detection(
268
+ detection[CLASS],
269
+ float(detection[CONFIDENCE]),
270
+ float(detection[X]),
271
+ float(detection[Y]),
272
+ float(detection[W]),
273
+ float(detection[H]),
274
+ )
275
+ detections.append(detected_item)
276
+ return detections
277
+
278
+
279
+ class FrameChunkParser:
280
+ @staticmethod
281
+ def parse(
282
+ file_path: Path,
283
+ frame_offset: int = 0,
284
+ ) -> FrameChunk:
285
+ input: dict[int, dict[str, Any]] = read_json(file_path)[DATA]
286
+ return FrameChunkParser.convert(input, file_path, frame_offset)
287
+
288
+ @staticmethod
289
+ def convert(
290
+ input: dict[int, dict[str, Any]],
291
+ file_path: Path,
292
+ frame_offset: int = 0,
293
+ ) -> FrameChunk:
294
+ detection_parser = DetectionParser()
295
+ frames = []
296
+ for key, value in input.items():
297
+ occurrence: datetime = parse_datetime(value[OCCURRENCE])
298
+ data_detections = value[DETECTIONS]
299
+ detections = detection_parser.convert(data_detections)
300
+ parsed_frame = Frame(
301
+ int(key) + frame_offset,
302
+ occurrence=occurrence,
303
+ input_file_path=file_path,
304
+ detections=detections,
305
+ )
306
+ frames.append(parsed_frame)
307
+
308
+ frames.sort(key=lambda frame: (frame.occurrence, frame.frame))
309
+ return FrameChunk(file_path, frames)
310
+
311
+
312
+ class Preprocess:
313
+ """Preprocess otdet file metadata (recording time interval) before running track.
314
+ Input files belonging to the same recording will be merged together.
315
+ The time gap to separate two recordings from each other is defined by
316
+ `self.time_without_frames`.
317
+
318
+ Returns:
319
+ Preprocess: preprocessor for tracking
320
+ """
321
+
322
+ def __init__(self, time_without_frames: timedelta = timedelta(minutes=1)) -> None:
323
+ self.time_without_frames = time_without_frames
324
+
325
+ def run(self, files: list[Path]) -> list[FrameGroup]:
326
+ """Read metadata of all input files,
327
+ parse the content and merge the frame groups belonging together.
328
+
329
+ Args:
330
+ files (list[Path]): list of input files
331
+
332
+ Returns:
333
+ list[FrameGroup]: merged frame groups sorted by start date
334
+ """
335
+
336
+ groups = self.process(self._read_input(files))
337
+ return sorted(groups, key=lambda r: r.start_date())
338
+
339
+ def process(self, input: dict[Path, dict]) -> list[FrameGroup]:
340
+ """Process given otdet files:
341
+ Create FrameGroup for each file then merge frame groups belonging together.
342
+
343
+ Args:
344
+ files (list[Path]): list of file paths
345
+
346
+ Returns:
347
+ list[FrameGroup]: parsed and merged frame groups
348
+ """
349
+ all_groups = [
350
+ self._parse_frame_group(path, metadata) for path, metadata in input.items()
351
+ ]
352
+ if len(all_groups) == 0:
353
+ return []
354
+ return self._merge_groups(all_groups)
355
+
356
+ def _read_input(self, files: list[Path]) -> dict[Path, dict]:
357
+ return {path: read_json_bz2_metadata(path) for path in files}
358
+
359
+ def _parse_frame_group(self, file_path: Path, metadata: dict) -> FrameGroup:
360
+ """Read and parse metadata of the given file to a FrameGroup
361
+ covering the recording time interval defined by:
362
+ - the recorded start date and
363
+ - the expected duration given in the metadata
364
+
365
+ Args:
366
+ file_path (Path): path of otdet file
367
+ metadata (dict): metadata of otdet file
368
+
369
+ Returns:
370
+ list[FrameGroup]: parsed input and metadata per file
371
+ """
372
+
373
+ start_date: datetime = self.extract_start_date_from(metadata)
374
+ duration: timedelta = self.extract_expected_duration_from(metadata)
375
+ end_date: datetime = start_date + duration
376
+ hostname = self.get_hostname(metadata)
377
+
378
+ return FrameGroup(
379
+ start_date=start_date,
380
+ end_date=end_date,
381
+ file=file_path,
382
+ metadata=metadata,
383
+ hostname=hostname,
384
+ )
385
+
386
+ @staticmethod
387
+ def get_hostname(file_metadata: dict) -> str:
388
+ """Retrieve hostname from the given file metadata.
389
+
390
+ Args:
391
+ file_metadata (dict): metadata content.
392
+
393
+ Raises:
394
+ InproperFormattedFilename: if the filename is not formatted as expected, an
395
+ exception will be raised.
396
+
397
+ Returns:
398
+ str: the hostname
399
+ """
400
+ video_name = Path(file_metadata[VIDEO][FILENAME]).name
401
+ match = re.search(
402
+ FULL_FILE_NAME_PATTERN,
403
+ video_name,
404
+ )
405
+ if match:
406
+ return match.group(HOSTNAME)
407
+
408
+ raise InproperFormattedFilename(f"Could not parse {video_name}.")
409
+
410
+ def _merge_groups(self, all_groups: list[FrameGroup]) -> list[FrameGroup]:
411
+ """Merge frame groups whose start and end times are close to each other. Close
412
+ is defined by `self.time_without_frames`.
413
+
414
+ Args:
415
+ all_groups (list[FrameGroup]): list of frame groups to merge
416
+
417
+ Returns:
418
+ list[FrameGroup]: list of merged frame groups
419
+ """
420
+ assert len(all_groups) >= 1
421
+
422
+ merged_groups = []
423
+ sorted_groups = sorted(all_groups, key=lambda group: group.start_date())
424
+ last_group = sorted_groups[0]
425
+ for current_group in sorted_groups[1:]:
426
+ if last_group.hostname != current_group.hostname:
427
+ merged_groups.append(last_group)
428
+ last_group = current_group
429
+ elif (
430
+ timedelta(seconds=0)
431
+ <= (current_group.start_date() - last_group.end_date())
432
+ <= self.time_without_frames
433
+ ):
434
+ last_group = last_group.merge(current_group)
435
+ else:
436
+ merged_groups.append(last_group)
437
+ last_group = current_group
438
+ merged_groups.append(last_group)
439
+ return merged_groups
440
+
441
+ def extract_start_date_from(self, metadata: dict) -> datetime:
442
+ if RECORDED_START_DATE in metadata[VIDEO].keys():
443
+ recorded_start_date = metadata[VIDEO][RECORDED_START_DATE]
444
+ return parse_datetime(recorded_start_date)
445
+ return MISSING_START_DATE
446
+
447
+ def extract_expected_duration_from(self, metadata: dict) -> timedelta:
448
+ if EXPECTED_DURATION in metadata[VIDEO].keys():
449
+ expected_duration = metadata[VIDEO][EXPECTED_DURATION]
450
+ return timedelta(seconds=int(expected_duration))
451
+ return MISSING_EXPECTED_DURATION