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,538 @@
1
+ """
2
+ OTVision helpers for filehandling
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 bz2
23
+ import logging
24
+ import shutil
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Iterable, Union
28
+
29
+ import ijson
30
+ import ujson
31
+
32
+ from OTVision import dataformat
33
+ from OTVision.config import CONFIG
34
+ from OTVision.dataformat import INPUT_FILE_PATH, METADATA
35
+ from OTVision.helpers.log import LOGGER_NAME
36
+
37
+ log = logging.getLogger(LOGGER_NAME)
38
+
39
+ ENCODING = "UTF-8"
40
+ COMPRESSED_FILETYPE = ".bz2"
41
+
42
+
43
+ START_DATE = "start_date"
44
+ FILE_NAME_PATTERN = r".*(?P<start_date>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}).*"
45
+ HOSTNAME = "hostname"
46
+ FULL_FILE_NAME_PATTERN = (
47
+ r"(?P<hostname>.*)_(?P<start_date>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}).*"
48
+ )
49
+
50
+
51
+ def get_files(
52
+ paths: list[Path],
53
+ filetypes: Union[list[str], None] = None,
54
+ search_subdirs: bool = True,
55
+ ) -> list[Path]:
56
+ """
57
+ Generates a list of files ending with filename based on filenames or the
58
+ (recursive) content of folders.
59
+
60
+ Args:
61
+ paths (list[Path]): where to find the files.
62
+ filetype (list[str]): ending of files to find. Preceding "_" prevents adding a
63
+ '.'
64
+ If no filetype is given, filetypes of file paths given are used and
65
+ directories are ignored. Defaults to None.
66
+ search_subdirs (bool): Wheter or not to search subdirs of dirs given as paths.
67
+ Defaults to True.
68
+
69
+ Raises:
70
+ TypeError: If type of paths is not list
71
+ TypeError: If type of path in paths is not Path or subclass
72
+ TypeError: If type of filetypes is not list
73
+ TypeError: If type of filetype in filetypes is not str
74
+ TypeError: If path in paths is neither valid file nor dir
75
+
76
+ Returns:
77
+ list[Path]: List of files
78
+ """
79
+ files = set()
80
+ if type(paths) is not list:
81
+ raise TypeError("Paths needs to be a list of pathlib.Path")
82
+ if filetypes:
83
+ if type(filetypes) is not list:
84
+ raise TypeError("Filetypes needs to be a list of str")
85
+ for idx, filetype in enumerate(filetypes):
86
+ if type(filetype) is not str:
87
+ raise TypeError("Filetypes needs to be a list of str")
88
+ if not filetype.startswith("_"):
89
+ if not filetype.startswith("."):
90
+ filetype = f".{filetype}"
91
+ filetypes[idx] = filetype.lower()
92
+ for path in paths:
93
+ if not isinstance(path, Path):
94
+ raise TypeError("Paths needs to be a list of pathlib.Path")
95
+ if path.is_file():
96
+ file = path
97
+ if filetypes:
98
+ for filetype in filetypes:
99
+ if path.suffix.lower() == filetype:
100
+ files.add(path)
101
+ else:
102
+ files.add(path)
103
+ elif path.is_dir():
104
+ if filetypes:
105
+ for filetype in filetypes:
106
+ for file in path.glob("**/*" if search_subdirs else "*"):
107
+ if file.is_file() and file.suffix.lower() == filetype:
108
+ files.add(file)
109
+ else:
110
+ raise TypeError("Paths needs to be a list of pathlib.Path")
111
+
112
+ return sorted(list(files))
113
+
114
+
115
+ def replace_filetype(
116
+ files: list[Path], new_filetype: str, old_filetype: Union[str, None] = None
117
+ ) -> list[Path]:
118
+ """In a list of files, replace the filetype of all files of a certain old_filetype
119
+ by a new_filetype. If no old_filetype is given, replace tha filetype of all files.
120
+ Directories remain unchanged in the new list.
121
+
122
+ Args:
123
+ files (list[Path]): List of paths (can be files or directories).
124
+ new_filetype (str): New file type after replacement.
125
+ old_filetype (str): File type to be replaced. If None, filetypes of all files
126
+ will be replaced.
127
+ Defaults to None.
128
+
129
+ Raises:
130
+ TypeError: If files is not a list of pathlib.Path
131
+ TypeError: If one of the files is not a file (but, for example, a dir)
132
+
133
+ Returns:
134
+ list[Path]: List of paths with file type replaced
135
+ """
136
+
137
+ if type(files) is not list:
138
+ raise TypeError("Paths needs to be a list of pathlib.Path")
139
+ new_paths = []
140
+ for path in files:
141
+ if not isinstance(path, Path):
142
+ raise TypeError("Paths needs to be a list of pathlib.Path")
143
+ if path.is_file():
144
+ if old_filetype and path.suffix.lower() != old_filetype.lower():
145
+ continue
146
+ new_path = path.with_suffix(new_filetype)
147
+ new_paths.append(new_path)
148
+ elif path.is_dir():
149
+ raise TypeError("files has to be a list of files without dirs")
150
+ else:
151
+ raise TypeError("files has to be a list of existing files")
152
+
153
+ return new_paths
154
+
155
+
156
+ def check_if_all_paths_exist(paths: list[Path]) -> None:
157
+ for path in paths:
158
+ if not path.expanduser().resolve().exists():
159
+ raise FileNotFoundError(f"{path} is not an existing file or directory")
160
+
161
+
162
+ def _remove_dir(dir_to_remove: Path) -> None:
163
+ """Helper to remove a directory and all of its subdirectories.
164
+
165
+ Args:
166
+ dir_to_remove (Path): directory to remove
167
+ """
168
+ for path in dir_to_remove.glob("*"):
169
+ if path.is_file():
170
+ path.unlink()
171
+ else:
172
+ _remove_dir(path)
173
+ dir_to_remove.rmdir()
174
+
175
+
176
+ def read_json_bz2_event_stream(path: Path) -> Iterable[tuple[str, str, str]]:
177
+ """
178
+ Provide lazy data stream reading the bzip2 compressed file
179
+ at the given path and interpreting it as json objects.
180
+ """
181
+ # TODO error handling
182
+ stream = bz2.BZ2File(path)
183
+ return ijson.parse(stream)
184
+
185
+
186
+ def metadata_from_json_events(parse_events: Iterable[tuple[str, str, str]]) -> dict:
187
+ """
188
+ Extract the metadata block of the ottrk data format
189
+ from the given json parser event stream.
190
+ """
191
+ result: dict
192
+ for data in ijson.items(parse_events, METADATA):
193
+ result = data
194
+ break
195
+ return result
196
+
197
+
198
+ def read_json_bz2_metadata(path: Path) -> dict:
199
+ try:
200
+ return metadata_from_json_events(read_json_bz2_event_stream(path))
201
+ except EOFError as cause:
202
+ log.exception(f'Unable to read "{path}" as JSON.', exc_info=cause)
203
+ raise cause
204
+
205
+
206
+ def read_json(
207
+ json_file: Path,
208
+ filetype: str = ".json",
209
+ decompress: bool = True,
210
+ ) -> dict:
211
+ """Read a json file of a specific filetype to a dict.
212
+
213
+ Args:
214
+ json_file (Path): json file to read
215
+ filetype (str, optional): filetype to check json file against.
216
+ Defaults to ".json".
217
+ decompress: (bool, optional): decompress output with bzip2.
218
+ If `filetype` is not `.bz2`, decompress will be set to False.
219
+ Defaults to True.
220
+
221
+ Raises:
222
+ TypeError: If file is not pathlib.Path
223
+ ValueError: If file is not of filetype given
224
+
225
+ Returns:
226
+ dict: dict read from json file
227
+ """
228
+ if not isinstance(json_file, Path):
229
+ raise TypeError("json_file has to be of type pathlib.Path")
230
+ filetype = json_file.suffix
231
+ if json_file.suffix != filetype:
232
+ raise ValueError(f"Wrong filetype {str(json_file)}, has to be {filetype}")
233
+ try:
234
+ t_json_start = time.perf_counter()
235
+ if decompress:
236
+ log.debug(f"Read and decompress {json_file}")
237
+ with bz2.open(json_file, "rt", encoding=ENCODING) as input:
238
+ dict_from_json_file = ujson.load(input)
239
+ else:
240
+ log.debug(f"Read {json_file} withoud decompression")
241
+ with open(json_file, "r", encoding=ENCODING) as input:
242
+ dict_from_json_file = ujson.load(input)
243
+ log.debug(f"Succesfully read {json_file}")
244
+ t_json_end = time.perf_counter()
245
+ log.debug(f"Reading {json_file} took: {t_json_end - t_json_start:0.4f}s")
246
+ return dict_from_json_file
247
+ except OSError as cause:
248
+ log.exception(f"Could not open {json_file}", exc_info=cause)
249
+ raise cause
250
+ except ujson.JSONDecodeError as cause:
251
+ log.exception(f'Unable to decode "{json_file}" as JSON.', exc_info=cause)
252
+ raise cause
253
+ except Exception as cause:
254
+ log.exception("", exc_info=cause)
255
+ raise cause
256
+
257
+
258
+ # TODO: Type hint nested dict during refactoring
259
+ def write_json(
260
+ dict_to_write: dict,
261
+ file: Path,
262
+ filetype: str = ".json",
263
+ overwrite: bool = False,
264
+ compress: bool = True,
265
+ ) -> None:
266
+ """Write a json file from a dict to a specific filetype.
267
+
268
+ Args:
269
+ dict_to_write (dict): dict to write
270
+ file (Path): file path. Can have other filetype, which will be overwritten.
271
+ filetype (str, optional): filetype of file to be written.
272
+ Defaults to ".json".
273
+ overwrite (bool, optional): Whether or not to overwrite an existing file.
274
+ Defaults to False.
275
+ compress: (bool, optional): compress input with bzip2.
276
+ If `filetype` is not `.bz2`, compress will be set to False.
277
+ Defaults to True.
278
+ """
279
+ outfile = Path(file).with_suffix(filetype)
280
+ outfile_already_exists = outfile.is_file()
281
+ if overwrite or not outfile_already_exists:
282
+ t_json_start = time.perf_counter()
283
+ if compress:
284
+ log.debug(f"Compress and write {outfile}")
285
+ with bz2.open(outfile, "wt", encoding=ENCODING) as output:
286
+ ujson.dump(dict_to_write, output)
287
+ else:
288
+ log.debug(f"Write {outfile} without compression")
289
+ with open(outfile, "w", encoding=ENCODING) as output:
290
+ ujson.dump(dict_to_write, output)
291
+ t_json_end = time.perf_counter()
292
+
293
+ if not outfile_already_exists:
294
+ log.debug(f"Successfully wrote {outfile}")
295
+ else:
296
+ log.debug(f"Successfully overwrote {outfile}")
297
+
298
+ log.debug(f"Writing {outfile} took: {t_json_end - t_json_start:0.4f}s")
299
+ else:
300
+ log.debug(f"{outfile} already exists, not overwritten. Set overwrite=True")
301
+
302
+
303
+ # TODO: Type hint nested dict during refactoring
304
+ def get_metadata(otdict: dict) -> dict:
305
+ """Check if dict of detections or tracks has subdict metadata.
306
+ If not, try to convert from historic format.
307
+
308
+ Args:
309
+ otdict (dict): dict of detections or tracks
310
+ """
311
+ if dataformat.METADATA in otdict:
312
+ return otdict[dataformat.METADATA]
313
+ try:
314
+ metadata = {}
315
+ if "vid_config" in otdict:
316
+ metadata[dataformat.VIDEO] = otdict["vid_config"]
317
+ if "det_config" in otdict:
318
+ metadata[dataformat.DETECTION] = otdict["det_config"]
319
+ if "trk_config" in otdict:
320
+ metadata[dataformat.TRACKING] = otdict["trk_config"]
321
+ log.info("new metadata created from historic information")
322
+ return metadata
323
+
324
+ except Exception:
325
+ log.exception("Metadata not found and not in historic config format")
326
+ raise
327
+
328
+
329
+ # TODO: Type hint nested dict during refactoring
330
+ def denormalize_bbox(
331
+ otdict: dict,
332
+ keys_width: Union[list[str], None] = None,
333
+ keys_height: Union[list[str], None] = None,
334
+ metadata: dict[str, dict] = {},
335
+ ) -> dict:
336
+ """Denormalize all bbox references in detections or tracks dict from percent to px.
337
+
338
+ Args:
339
+ otdict (dict): dict of detections or tracks
340
+ keys_width (list[str], optional): list of keys describing horizontal position.
341
+ Defaults to ["x", "w"].
342
+ keys_height (list[str], optional): list of keys describing vertical position.
343
+ Defaults to ["y", "h"].
344
+ metadata (dict[str, dict]): dict of metadata per input file.
345
+
346
+ Returns:
347
+ _type_: Denormalized dict.
348
+ """
349
+ if keys_width is None:
350
+ keys_width = [dataformat.X, dataformat.W]
351
+ if keys_height is None:
352
+ keys_height = [dataformat.Y, dataformat.H]
353
+ log.debug("Denormalize frame wise")
354
+ otdict = _denormalize_transformation(otdict, keys_width, keys_height, metadata)
355
+ return otdict
356
+
357
+
358
+ # TODO: Type hint nested dict during refactoring
359
+ def _denormalize_transformation(
360
+ otdict: dict,
361
+ keys_width: list[str],
362
+ keys_height: list[str],
363
+ metadata: dict[str, dict] = {},
364
+ ) -> dict:
365
+ """Helper to do the actual denormalization.
366
+
367
+ Args:
368
+ otdict (dict): dict of detections or tracks
369
+ keys_width (list[str]): list of keys describing horizontal position.
370
+ Defaults to ["x", "w"].
371
+ keys_height (list[str]): list of keys describing vertical position.
372
+ Defaults to ["y", "h"].
373
+ metadata (dict[str, dict]): dict of metadata per input file.
374
+
375
+ Returns:
376
+ dict: denormalized dict
377
+ """
378
+ changed_files = set()
379
+
380
+ for frame in otdict[dataformat.DATA].values():
381
+ input_file = frame[INPUT_FILE_PATH]
382
+ metadate = metadata[input_file]
383
+ width = metadate[dataformat.VIDEO][dataformat.WIDTH]
384
+ height = metadate[dataformat.VIDEO][dataformat.HEIGHT]
385
+ is_normalized = metadate[dataformat.DETECTION][dataformat.NORMALIZED_BBOX]
386
+ if is_normalized:
387
+ changed_files.add(input_file)
388
+ for bbox in frame[dataformat.DETECTIONS]:
389
+ for key in bbox:
390
+ if key in keys_width:
391
+ bbox[key] = bbox[key] * width
392
+ elif key in keys_height:
393
+ bbox[key] = bbox[key] * height
394
+
395
+ for file in changed_files:
396
+ metadata[file][dataformat.DETECTION][dataformat.NORMALIZED_BBOX] = False
397
+ return otdict
398
+
399
+
400
+ # TODO: Type hint nested dict during refactoring
401
+ def normalize_bbox(
402
+ otdict: dict,
403
+ keys_width: Union[list[str], None] = None,
404
+ keys_height: Union[list[str], None] = None,
405
+ metadata: dict[str, dict] = {},
406
+ ) -> dict:
407
+ """Normalize all bbox references in detections or tracks dict from percent to px.
408
+
409
+ Args:
410
+ otdict (dict): dict of detections or tracks
411
+ keys_width (list[str], optional): list of keys describing horizontal position.
412
+ Defaults to ["x", "w"].
413
+ keys_height (list[str], optional): list of keys describing vertical position.
414
+ Defaults to ["y", "h"].
415
+ metadata (dict[str, dict]): dict of metadata per input file.
416
+
417
+ Returns:
418
+ _type_: Normalized dict.
419
+ """
420
+ if keys_width is None:
421
+ keys_width = [dataformat.X, dataformat.W]
422
+ if keys_height is None:
423
+ keys_height = [dataformat.Y, dataformat.H]
424
+ if not otdict[dataformat.METADATA][dataformat.NORMALIZED_BBOX]:
425
+ otdict = _normalize_transformation(
426
+ otdict,
427
+ keys_width,
428
+ keys_height,
429
+ metadata,
430
+ )
431
+ log.debug("Dict normalized")
432
+ else:
433
+ log.debug("Dict was already normalized")
434
+ return otdict
435
+
436
+
437
+ # TODO: Type hint nested dict during refactoring
438
+ def _normalize_transformation(
439
+ otdict: dict,
440
+ keys_width: list[str],
441
+ keys_height: list[str],
442
+ metadata: dict[str, dict] = {},
443
+ ) -> dict:
444
+ """Helper to do the actual normalization.
445
+
446
+ Args:
447
+ otdict (dict): dict of detections or tracks
448
+ keys_width (list[str]): list of keys describing horizontal position.
449
+ Defaults to ["x", "w"].
450
+ keys_height (list[str]): list of keys describing vertical position.
451
+ Defaults to ["y", "h"].
452
+
453
+
454
+ Returns:
455
+ dict: Normalized dict
456
+ """
457
+ changed_files = set()
458
+
459
+ for frame in otdict[dataformat.DATA].values():
460
+ input_file = frame[INPUT_FILE_PATH]
461
+ metadate = metadata[input_file]
462
+ width = metadate[dataformat.VIDEO][dataformat.WIDTH]
463
+ height = metadate[dataformat.VIDEO][dataformat.HEIGHT]
464
+ is_denormalized = not metadate[dataformat.NORMALIZED_BBOX]
465
+ if is_denormalized:
466
+ changed_files.add(input_file)
467
+ for bbox in frame[dataformat.DETECTIONS]:
468
+ for key in bbox:
469
+ if key in keys_width:
470
+ bbox[key] = bbox[key] / width
471
+ elif key in keys_height:
472
+ bbox[key] = bbox[key] / height
473
+
474
+ for file in changed_files:
475
+ metadata[file][dataformat.NORMALIZED_BBOX] = True
476
+ return otdict
477
+
478
+
479
+ def has_filetype(file: Path, filetypes: list[str]) -> bool:
480
+ """Checks if a file has a specified filetype.
481
+
482
+ The case of a filetype is ignored.
483
+
484
+ Args:
485
+ file (Path): The path to the file
486
+ file_formats(list(str)): The valid filetypes
487
+
488
+ Returns:
489
+ True if file is of filetype specified in filetypes.
490
+ Otherwise False.
491
+ """
492
+
493
+ return file.suffix.lower() in [
494
+ filetype.lower() if filetype.startswith(".") else f".{filetype.lower()}"
495
+ for filetype in filetypes
496
+ ]
497
+
498
+
499
+ def is_video(file: Path) -> bool:
500
+ """Checks if a file is a video according to its filetype
501
+
502
+ Args:
503
+ file (Path): file to check
504
+
505
+ Returns:
506
+ bool: whether or not the file is a video
507
+ """
508
+ return file.suffix.lower() in CONFIG["FILETYPES"]["VID"]
509
+
510
+
511
+ def is_image(file: Path) -> bool:
512
+ """Checks if a file is an image according to its filetype
513
+
514
+ Args:
515
+ file (Path): file to check
516
+
517
+ Returns:
518
+ bool: whether or not the file is an image
519
+ """
520
+ return file.suffix.lower() in CONFIG["FILETYPES"]["IMG"]
521
+
522
+
523
+ def unzip(file: Path) -> Path:
524
+ """Unpack a zip archive to a directory of same name.
525
+
526
+ Args:
527
+ file (Path): zip to unpack
528
+
529
+ Returns:
530
+ Path: unzipped directory
531
+ """
532
+ directory = file.with_suffix("")
533
+ shutil.unpack_archive(file, directory)
534
+ return directory
535
+
536
+
537
+ class InproperFormattedFilename(Exception):
538
+ pass
@@ -0,0 +1,139 @@
1
+ """
2
+ OTVision helpers to change formats and retrieve information
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
+
23
+ import datetime as dt
24
+ import re
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+
30
+ def _get_fps_from_filename(filename: str) -> int:
31
+ """Get frame rate from file name using regex.
32
+ Returns None if frame rate is not found in file name.
33
+
34
+ Args:
35
+ input_filename (str): file name
36
+
37
+ Returns:
38
+ int or None: frame rate in frames per second or None
39
+ """
40
+
41
+ if match := re.search(r"_FR([\d]+)_", filename):
42
+ return int(match[1])
43
+ else:
44
+ raise ValueError(f"Cannot read frame rate from file name {filename}")
45
+
46
+
47
+ def _get_datetime_from_filename(
48
+ filename: str, epoch_datetime: str = "1970-01-01_00-00-00"
49
+ ) -> str:
50
+ """Get date and time from file name.
51
+ Searches for "_yyyy-mm-dd_hh-mm-ss".
52
+ Returns "yyyy-mm-dd_hh-mm-ss".
53
+
54
+ Args:
55
+ filename (str): filename with expression
56
+ epoch_datetime (str): Unix epoch (00:00:00 on 1 January 1970)
57
+
58
+ Returns:
59
+ str: datetime
60
+ """
61
+ regex = "_([0-9]{4,4}-[0-9]{2,2}-[0-9]{2,2}_[0-9]{2,2}-[0-9]{2,2}-[0-9]{2,2})"
62
+ match = re.search(regex, filename)
63
+ if not match:
64
+ return epoch_datetime
65
+
66
+ # Assume that there is only one timestamp in the file name
67
+ datetime_str = match[1]
68
+
69
+ try:
70
+ dt.datetime.strptime(datetime_str, "%Y-%m-%d_%H-%M-%S")
71
+ except ValueError:
72
+ return epoch_datetime
73
+
74
+ return datetime_str
75
+
76
+
77
+ # TODO: Type hint nested dict during refactoring
78
+ def _ottrk_detections_to_df(ottrk: list) -> pd.DataFrame:
79
+ """Turns a list of tracked detections into a dataframe
80
+
81
+ Args:
82
+ ottrk (list): List of dicts, each dict is a tracked detection.
83
+ Comes from from .ottrk file.
84
+
85
+ Returns:
86
+ pd.DataFrame: DataFrame of tracks
87
+ """
88
+ return pd.DataFrame(ottrk)
89
+
90
+
91
+ def _get_time_from_frame_number(
92
+ frame_series: pd.Series,
93
+ start_datetime: str,
94
+ fps: int,
95
+ return_yyyymmdd_hhmmss: bool = True,
96
+ return_milliseconds: bool = True,
97
+ ) -> pd.Series | tuple[pd.Series, pd.Series]:
98
+ """Get datetime series of detections from series of frame numbers of video
99
+ the objects were detected using a start datetime of the video and
100
+ the video frame rate (fps).
101
+
102
+ Args:
103
+ frame_series (pd.Series): Series of video frames of detections
104
+ start_datetime (str): Start datetime of video
105
+ fps (int): Video frame rate in frames per second
106
+
107
+ Returns:
108
+ pd.Series: Datetime series of detections in "%Y-%m-%d_%H-%M-%S" format
109
+ pd.Series: Datetime series of detections in milliseconds
110
+ """
111
+ datetime_yyyymmdd_hhmmss = pd.to_datetime(
112
+ start_datetime, format=r"%Y-%m-%d_%H-%M-%S"
113
+ ) + pd.to_timedelta((frame_series.astype("int32") - 1) / fps, unit="s")
114
+ if return_milliseconds:
115
+ datetime_milliseconds = datetime_yyyymmdd_hhmmss.astype(np.int64) / int(1e6)
116
+ if return_yyyymmdd_hhmmss and return_milliseconds:
117
+ return datetime_yyyymmdd_hhmmss, datetime_milliseconds
118
+ elif not return_milliseconds:
119
+ return datetime_yyyymmdd_hhmmss
120
+ else:
121
+ return datetime_milliseconds
122
+
123
+
124
+ def _get_epsg_from_utm_zone(utm_zone: int, hemisphere: str) -> int:
125
+ """Calculates the epsg number from utm zone and hemisphere.
126
+
127
+ Args:
128
+ utm_zone (int): UTM zone (1-60)
129
+ hemisphere (str): Hemisphere ("N" or "S")
130
+
131
+ Returns:
132
+ int: epsg number of UTM zone (e.g. 32632)
133
+ """
134
+ identifier_digits = 32000
135
+ if hemisphere == "N":
136
+ hemisphere_digit = 600
137
+ elif hemisphere == "S":
138
+ hemisphere_digit = 700
139
+ return identifier_digits + hemisphere_digit + utm_zone