boris-behav-obs 8.12__py3-none-any.whl → 9.7.6__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.

Potentially problematic release.


This version of boris-behav-obs might be problematic. Click here for more details.

Files changed (128) hide show
  1. boris/__init__.py +1 -1
  2. boris/__main__.py +1 -1
  3. boris/about.py +28 -39
  4. boris/add_modifier.py +122 -109
  5. boris/add_modifier_ui.py +239 -135
  6. boris/advanced_event_filtering.py +81 -45
  7. boris/analysis_plugins/__init__.py +0 -0
  8. boris/analysis_plugins/_latency.py +59 -0
  9. boris/analysis_plugins/irr_cohen_kappa.py +109 -0
  10. boris/analysis_plugins/irr_cohen_kappa_with_modifiers.py +112 -0
  11. boris/analysis_plugins/irr_weighted_cohen_kappa.py +157 -0
  12. boris/analysis_plugins/irr_weighted_cohen_kappa_with_modifiers.py +162 -0
  13. boris/analysis_plugins/list_of_dataframe_columns.py +22 -0
  14. boris/analysis_plugins/number_of_occurences.py +22 -0
  15. boris/analysis_plugins/number_of_occurences_by_independent_variable.py +54 -0
  16. boris/analysis_plugins/time_budget.py +61 -0
  17. boris/behav_coding_map_creator.py +228 -229
  18. boris/behavior_binary_table.py +33 -50
  19. boris/behaviors_coding_map.py +17 -18
  20. boris/boris_cli.py +6 -25
  21. boris/cmd_arguments.py +12 -1
  22. boris/coding_pad.py +42 -49
  23. boris/config.py +141 -65
  24. boris/config_file.py +58 -67
  25. boris/connections.py +107 -61
  26. boris/converters.py +13 -37
  27. boris/converters_ui.py +187 -110
  28. boris/cooccurence.py +250 -0
  29. boris/core.py +2373 -1786
  30. boris/core_qrc.py +15895 -10743
  31. boris/core_ui.py +943 -798
  32. boris/db_functions.py +17 -42
  33. boris/dev.py +109 -8
  34. boris/dialog.py +482 -236
  35. boris/duration_widget.py +9 -14
  36. boris/edit_event.py +61 -31
  37. boris/edit_event_ui.py +208 -97
  38. boris/event_operations.py +408 -293
  39. boris/events_cursor.py +25 -17
  40. boris/events_snapshots.py +36 -82
  41. boris/exclusion_matrix.py +4 -9
  42. boris/export_events.py +184 -223
  43. boris/export_observation.py +74 -100
  44. boris/external_processes.py +123 -98
  45. boris/geometric_measurement.py +644 -290
  46. boris/gui_utilities.py +91 -14
  47. boris/image_overlay.py +4 -4
  48. boris/import_observations.py +190 -98
  49. boris/ipc_mpv.py +325 -0
  50. boris/irr.py +20 -57
  51. boris/latency.py +31 -24
  52. boris/measurement_widget.py +14 -18
  53. boris/media_file.py +17 -19
  54. boris/menu_options.py +17 -6
  55. boris/modifier_coding_map_creator.py +1013 -0
  56. boris/modifiers_coding_map.py +7 -9
  57. boris/mpv.py +1 -0
  58. boris/mpv2.py +732 -705
  59. boris/observation.py +533 -221
  60. boris/observation_operations.py +1025 -390
  61. boris/observation_ui.py +572 -362
  62. boris/observations_list.py +71 -53
  63. boris/otx_parser.py +74 -68
  64. boris/param_panel.py +31 -16
  65. boris/param_panel_ui.py +254 -138
  66. boris/player_dock_widget.py +90 -60
  67. boris/plot_data_module.py +25 -33
  68. boris/plot_events.py +127 -90
  69. boris/plot_events_rt.py +17 -31
  70. boris/plot_spectrogram_rt.py +95 -30
  71. boris/plot_waveform_rt.py +32 -21
  72. boris/plugins.py +431 -0
  73. boris/portion/__init__.py +18 -8
  74. boris/portion/const.py +35 -18
  75. boris/portion/dict.py +5 -5
  76. boris/portion/func.py +2 -2
  77. boris/portion/interval.py +21 -41
  78. boris/portion/io.py +41 -32
  79. boris/preferences.py +306 -83
  80. boris/preferences_ui.py +684 -227
  81. boris/project.py +448 -293
  82. boris/project_functions.py +671 -238
  83. boris/project_import_export.py +213 -222
  84. boris/project_ui.py +674 -438
  85. boris/qrc_boris.py +6 -3
  86. boris/qrc_boris5.py +6 -3
  87. boris/select_modifiers.py +74 -48
  88. boris/select_observations.py +20 -198
  89. boris/select_subj_behav.py +67 -39
  90. boris/state_events.py +52 -35
  91. boris/subjects_pad.py +6 -9
  92. boris/synthetic_time_budget.py +45 -28
  93. boris/time_budget_functions.py +171 -171
  94. boris/time_budget_widget.py +84 -114
  95. boris/transitions.py +41 -47
  96. boris/utilities.py +627 -236
  97. boris/version.py +3 -3
  98. boris/video_equalizer.py +16 -14
  99. boris/video_equalizer_ui.py +199 -130
  100. boris/video_operations.py +95 -29
  101. boris/view_df.py +104 -0
  102. boris/view_df_ui.py +75 -0
  103. boris/write_event.py +538 -0
  104. boris_behav_obs-9.7.6.dist-info/METADATA +139 -0
  105. boris_behav_obs-9.7.6.dist-info/RECORD +109 -0
  106. {boris_behav_obs-8.12.dist-info → boris_behav_obs-9.7.6.dist-info}/WHEEL +1 -1
  107. boris_behav_obs-9.7.6.dist-info/entry_points.txt +2 -0
  108. boris/README.TXT +0 -22
  109. boris/add_modifier.ui +0 -323
  110. boris/converters.ui +0 -289
  111. boris/core.qrc +0 -36
  112. boris/core.ui +0 -1556
  113. boris/edit_event.ui +0 -233
  114. boris/icons/logo_eye.ico +0 -0
  115. boris/map_creator.py +0 -850
  116. boris/observation.ui +0 -814
  117. boris/param_panel.ui +0 -379
  118. boris/preferences.ui +0 -537
  119. boris/project.ui +0 -1069
  120. boris/project_server.py +0 -236
  121. boris/vlc.py +0 -10343
  122. boris/vlc_local.py +0 -90
  123. boris_behav_obs-8.12.dist-info/LICENSE.TXT +0 -674
  124. boris_behav_obs-8.12.dist-info/METADATA +0 -128
  125. boris_behav_obs-8.12.dist-info/RECORD +0 -108
  126. boris_behav_obs-8.12.dist-info/entry_points.txt +0 -3
  127. {boris → boris_behav_obs-9.7.6.dist-info/licenses}/LICENSE.TXT +0 -0
  128. {boris_behav_obs-8.12.dist-info → boris_behav_obs-9.7.6.dist-info}/top_level.txt +0 -0
boris/utilities.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  BORIS
3
3
  Behavioral Observation Research Interactive Software
4
- Copyright 2012-2023 Olivier Friard
4
+ Copyright 2012-2025 Olivier Friard
5
5
 
6
6
  This program is free software; you can redistribute it and/or modify
7
7
  it under the terms of the GNU General Public License as published by
@@ -19,33 +19,243 @@ Copyright 2012-2023 Olivier Friard
19
19
  MA 02110-1301, USA.
20
20
  """
21
21
 
22
- from cmath import isnan
22
+ from decimal import Decimal as dec
23
+ from decimal import getcontext, ROUND_DOWN
24
+ from hachoir.metadata import extractMetadata
25
+ from hachoir.parser import createParser
26
+ from shutil import copyfile, which
27
+ from typing import Union, Tuple
23
28
  import csv
24
29
  import datetime
25
- import hashlib
30
+ import datetime as dt
31
+ import exifread
26
32
  import json
27
33
  import logging
28
34
  import math
35
+ import numpy as np
29
36
  import os
30
- import pathlib as pl
37
+ from pathlib import Path
38
+ from PIL.ImageQt import Image
39
+ import platform
31
40
  import re
32
- import socket
41
+ import shutil
33
42
  import subprocess
34
43
  import sys
35
44
  import urllib.parse
45
+ import urllib.request
36
46
  import wave
37
- from decimal import Decimal as dec
38
- from decimal import getcontext, ROUND_DOWN
39
- from shutil import copyfile
40
- from typing import Union, Optional, List, Tuple, Dict
41
-
42
- import numpy as np
43
- from PyQt5.QtGui import qRgb
44
- from PyQt5.QtGui import QPixmap, QImage
47
+ import socket
45
48
 
46
- from PIL.ImageQt import Image
49
+ from PySide6 import __version__ as pyside6_version
50
+ from PySide6.QtGui import QPixmap, QImage
51
+ from PySide6.QtCore import qVersion
47
52
 
48
53
  from . import config as cfg
54
+ from . import version
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ if (sys.platform.startswith("win") or sys.platform.startswith("linux")) and ("-i" not in sys.argv) and ("--ipc" not in sys.argv):
59
+ try:
60
+ from . import mpv2 as mpv
61
+ except Exception:
62
+ logger.warning("MPV library not found")
63
+
64
+ if sys.platform.startswith("win"):
65
+ import ctypes
66
+
67
+ logger.info("The MPV library was not found!\nIt will be downloaded from the BORIS GitHub repository")
68
+ # ctypes.windll.user32.MessageBoxW(0, "The MPV library was not found!\nIt will be downloaded.", "BORIS", 0)
69
+
70
+ # test if following function works on windows
71
+ MessageBoxTimeoutW = ctypes.windll.user32.MessageBoxTimeoutW
72
+ MessageBoxTimeoutW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint]
73
+ ctypes.windll.user32.MessageBoxTimeoutW(
74
+ None, "The MPV library was not found.\nIt will be downloaded from the BORIS GitHub repository.", "MPV library", 0, 0, 10000
75
+ ) # time out
76
+
77
+ # download libmpv2.dll from https://github.com/boris-behav-obs/boris-behav-obs.github.io/releases/download/files/
78
+
79
+ url: str = "https://github.com/boris-behav-obs/boris-behav-obs.github.io/releases/download/files/"
80
+
81
+ external_files_dir = ""
82
+ # search where to download libmpv-2.dll
83
+
84
+ external_files_dir = Path(__file__).parent / "misc"
85
+ if not external_files_dir.is_dir():
86
+ logger.info(f"Creating {external_files_dir} directory")
87
+ external_files_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ logger.info(f"MPV library directory: {external_files_dir}")
90
+
91
+ local_filename = external_files_dir / "libmpv-2.dll"
92
+ logger.info("Downloading libmpv-2.dll...")
93
+ try:
94
+ urllib.request.urlretrieve(url + "libmpv-2.dll", local_filename)
95
+ logger.info(f"File downloaded as {local_filename}")
96
+ except Exception:
97
+ logger.critical("The MPV library can not be downloaded! Check your connection.")
98
+ ctypes.windll.user32.MessageBoxW(0, "The MPV library can not be downloaded!\nCheck your connection.", "BORIS", 0)
99
+ sys.exit(5)
100
+ # reload package
101
+ try:
102
+ from . import mpv2 as mpv
103
+ except Exception:
104
+ logger.critical("MPV library not found after dowloading")
105
+ sys.exit(5)
106
+
107
+ elif sys.platform.startswith("linux"):
108
+ text = (
109
+ "The MPV library was not found!\nInstall it\n\n"
110
+ "With Debian/Ubuntu/Mint:\nsudo apt install libmpv2\n\n"
111
+ "With Fedora:\nsudo dnf install mpv-libs\n\n"
112
+ "With OpenSUSE:\nsudo zypper install mpv\n\n"
113
+ "Arch Linux / Manjaro:\nsudo pacman -S mpv\n\n"
114
+ )
115
+ if shutil.which("zenity") is not None:
116
+ subprocess.run(["zenity", "--error", f"--text={text}"])
117
+ elif shutil.which("kdialog"):
118
+ subprocess.run(["kdialog", "--msgbox", text])
119
+ elif shutil.which("gxmessage"):
120
+ subprocess.run(["gxmessage", text])
121
+ elif shutil.which("xmessage"):
122
+ subprocess.run(["xmessage", text])
123
+
124
+ sys.exit(5)
125
+ else:
126
+ sys.exit(5)
127
+
128
+
129
+ def test_mpv_ipc(socket_path: str = cfg.MPV_SOCKET) -> bool:
130
+ """
131
+ test if socket available
132
+ """
133
+ if not os.path.exists(socket_path):
134
+ return False
135
+
136
+ try:
137
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
138
+ client.connect(socket_path)
139
+ client.close()
140
+ return True
141
+ except Exception:
142
+ return False
143
+
144
+
145
+ def extract_exif_DateTimeOriginal(file_path: str) -> int:
146
+ """
147
+ extract the EXIF DateTimeOriginal tag
148
+ return epoch time
149
+ if the tag is not available return -1
150
+
151
+ Args:
152
+ file_path (str): path of the media file
153
+
154
+ Returns:
155
+ int: timestamp
156
+
157
+ """
158
+ try:
159
+ with open(file_path, "rb") as f_in:
160
+ tags = exifread.process_file(f_in, details=False, stop_tag="EXIF DateTimeOriginal")
161
+ if "EXIF DateTimeOriginal" in tags:
162
+ date_time_original = (
163
+ f"{tags['EXIF DateTimeOriginal'].values[:4]}-"
164
+ f"{tags['EXIF DateTimeOriginal'].values[5:7]}-"
165
+ f"{tags['EXIF DateTimeOriginal'].values[8:10]} "
166
+ f"{tags['EXIF DateTimeOriginal'].values.split(' ')[-1]}"
167
+ )
168
+ return int(datetime.datetime.strptime(date_time_original, "%Y-%m-%d %H:%M:%S").timestamp())
169
+ else:
170
+ try:
171
+ # read from file name (YYYY-MM-DD_HHMMSS)
172
+ return int(datetime.datetime.strptime(Path(file_path).stem, "%Y-%m-%d_%H%M%S").timestamp())
173
+ except Exception:
174
+ # read from file name (YYYY-MM-DD_HH:MM:SS)
175
+ return int(datetime.datetime.strptime(Path(file_path).stem, "%Y-%m-%d_%H:%M:%S").timestamp())
176
+
177
+ except Exception:
178
+ return -1
179
+
180
+
181
+ def extract_video_creation_date(file_path: str) -> int | None:
182
+ """
183
+ returns the timestamp of the media creation date time with Hachoir
184
+ """
185
+
186
+ logger.debug(f"extract_video_creation_date for {file_path}")
187
+
188
+ if not Path(file_path).is_file():
189
+ logger.debug(f"{file_path} not found")
190
+ return None
191
+ try:
192
+ parser = createParser(file_path)
193
+ metadata = extractMetadata(parser)
194
+ except Exception:
195
+ return None
196
+
197
+ if metadata.has("creation_date"):
198
+ if metadata.get("creation_date") == datetime.datetime(1904, 1, 1, 0, 0):
199
+ return None
200
+ return metadata.get("creation_date").timestamp()
201
+ else:
202
+ return None
203
+
204
+
205
+ def extract_date_time_from_file_name(file_path: str) -> int | None:
206
+ """
207
+ extract YYYY-MM-DD_HHMMSS or YYYY-MM-DD_HH:MM:SS from file name
208
+ """
209
+
210
+ patterns = (r"\d{4}-\d{2}-\d{2}_\d{6}", r"\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2}")
211
+ for pattern in patterns:
212
+ matches = re.findall(pattern, file_path)
213
+
214
+ if matches:
215
+ if pattern == r"\d{4}-\d{2}-\d{2}_\d{6}":
216
+ logger.debug(
217
+ f"extract_date_time_from_file_name timestamp from {file_path}: {int(datetime.datetime.strptime(matches[0], '%Y-%m-%d_%H%M%S').timestamp())}"
218
+ )
219
+
220
+ return int(datetime.datetime.strptime(matches[0], "%Y-%m-%d_%H%M%S").timestamp())
221
+
222
+ if pattern == r"\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2}":
223
+ logger.debug(
224
+ f"extract_date_time_from_file_name timestamp from {file_path}: {int(datetime.datetime.strptime(matches[0], '%Y-%m-%d_%H:%M:%S').timestamp())}"
225
+ )
226
+
227
+ return int(datetime.datetime.strptime(matches[0], "%Y-%m-%d_%H:%M:%S").timestamp())
228
+
229
+ return None
230
+
231
+
232
+ def mpv_lib_version() -> Tuple[str, str, str]:
233
+ """
234
+ Version of MPV library
235
+
236
+ Returns:
237
+ str: MPV library version
238
+ """
239
+ if ("-i" in sys.argv) or ("--ipc" in sys.argv) or sys.platform.startswith("darwin"):
240
+ return "MPV IPC mode", "", ""
241
+
242
+ mpv_lib_file = None
243
+ if sys.platform.startswith("linux"):
244
+ mpv_lib_file = mpv.sofile
245
+ if sys.platform.startswith("win"):
246
+ mpv_lib_file = mpv.dll
247
+
248
+ return (".".join([str(x) for x in mpv._mpv_client_api_version()]), mpv_lib_file, mpv.MPV_VERSION)
249
+
250
+
251
+ def python_mpv_script_version() -> str:
252
+ """
253
+ version of python-mpv script
254
+ """
255
+ try:
256
+ return mpv.__version__
257
+ except Exception:
258
+ return "Not found"
49
259
 
50
260
 
51
261
  def error_info(exc_info: tuple) -> tuple:
@@ -156,28 +366,7 @@ def return_file_header_footer(file_name: str, file_row_number: int = 0, row_numb
156
366
  return header, footer
157
367
 
158
368
 
159
- def bytes_to_str(b: bytes) -> str:
160
- """
161
- Translate bytes to string.
162
-
163
- Args:
164
- b (bytes): byte to convert
165
-
166
- Returns:
167
- str: converted byte
168
- """
169
-
170
- if isinstance(b, bytes):
171
- fileSystemEncoding = sys.getfilesystemencoding()
172
- # hack for PyInstaller
173
- if fileSystemEncoding is None:
174
- fileSystemEncoding = "UTF-8"
175
- return b.decode(fileSystemEncoding)
176
- else:
177
- return b
178
-
179
-
180
- def convertTime(time_format: str, sec: float) -> str:
369
+ def convertTime(time_format: str, sec: Union[float, dec]) -> Union[str, None]:
181
370
  """
182
371
  convert time in base at the current format (S or HHMMSS)
183
372
 
@@ -200,6 +389,20 @@ def convertTime(time_format: str, sec: float) -> str:
200
389
  return None
201
390
 
202
391
 
392
+ def smart_time_format(sec: Union[float, dec], time_format: str = cfg.S, cutoff: dec = cfg.SMART_TIME_CUTOFF_DEFAULT) -> str:
393
+ """
394
+ Smart time format
395
+ returns time in seconds if <= cutoff else in HH:MM:SS.ZZZ format
396
+ """
397
+ # cutoff = 0 follows the time format selectd by user
398
+ if cutoff == 0:
399
+ return convertTime(time_format, sec)
400
+ if sec <= cutoff:
401
+ return f"{sec:.3f}"
402
+ else:
403
+ return seconds2time(sec)
404
+
405
+
203
406
  def convert_time_to_decimal(pj: dict) -> dict:
204
407
  """
205
408
  convert time of project from float to decimal
@@ -212,7 +415,10 @@ def convert_time_to_decimal(pj: dict) -> dict:
212
415
  """
213
416
  for obs_id in pj[cfg.OBSERVATIONS]:
214
417
  if cfg.TIME_OFFSET in pj[cfg.OBSERVATIONS][obs_id]:
215
- pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] = dec(str(pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET]))
418
+ if pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] is not None:
419
+ pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] = dec(str(pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET]))
420
+ else:
421
+ pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] = dec("0.000")
216
422
  for idx, _ in enumerate(pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS]):
217
423
  pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS][idx][cfg.EVENT_TIME_FIELD_IDX] = dec(
218
424
  pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS][idx][cfg.EVENT_TIME_FIELD_IDX]
@@ -221,18 +427,11 @@ def convert_time_to_decimal(pj: dict) -> dict:
221
427
  return pj
222
428
 
223
429
 
224
- def file_content_md5(file_name: str) -> str:
430
+ def count_media_file(media_files: dict) -> int:
225
431
  """
226
- returns the MD5 sum of file content
432
+ count number of media file for observation
227
433
  """
228
- hash_md5 = hashlib.md5()
229
- try:
230
- with open(file_name, "rb") as f:
231
- for chunk in iter(lambda: f.read(4096), b""):
232
- hash_md5.update(chunk)
233
- return hash_md5.hexdigest()
234
- except FileNotFoundError:
235
- return ""
434
+ return sum([len(media_files[idx]) for idx in media_files])
236
435
 
237
436
 
238
437
  def txt2np_array(
@@ -269,24 +468,40 @@ def txt2np_array(
269
468
  np_converters: dict = {}
270
469
  for column_idx in column_converter:
271
470
  if column_converter[column_idx] in converters:
272
-
273
471
  conv_name = column_converter[column_idx]
274
472
 
275
473
  function = f"""def {conv_name}(INPUT):\n"""
276
- function += """ INPUT = INPUT.decode("utf-8") if isinstance(INPUT, bytes) else INPUT"""
474
+ function += """ INPUT = INPUT.decode("utf-8") if isinstance(INPUT, bytes) else INPUT\n\n"""
277
475
  for line in converters[conv_name]["code"].split("\n"):
278
476
  function += f" {line}\n"
279
477
  function += """ return OUTPUT"""
280
478
 
479
+ print("=============")
480
+ print(function)
481
+ print("=============")
482
+
483
+ import types
484
+
485
+ mod = types.ModuleType("converter_module")
486
+ exec(function, mod.__dict__)
487
+
488
+ """
281
489
  try:
282
490
  exec(function)
283
491
  except Exception:
284
492
  return False, f"error in converter: {sys.exc_info()[1]}", np.array([])
285
493
 
286
- np_converters[column_idx - 1] = locals()[conv_name]
494
+ print(f"{converters=}")
495
+ print(f"{column_converter=}")
496
+ print(locals())
497
+ print(f"{conv_name=}")
498
+ """
499
+
500
+ # np_converters[column_idx - 1] = locals()['conv_name']
501
+ np_converters[column_idx - 1] = getattr(mod, conv_name)
287
502
 
288
503
  else:
289
- return False, f"converter {cfg.converters_param[column_idx]} not found", np.array([])
504
+ return False, f"converter {column_converter[column_idx]} not found", np.array([])
290
505
 
291
506
  # snif txt file
292
507
  try:
@@ -308,9 +523,7 @@ def txt2np_array(
308
523
  return False, f"{sys.exc_info()[1]}", np.array([])
309
524
 
310
525
  try:
311
- data = np.loadtxt(
312
- file_name, delimiter=dialect.delimiter, usecols=columns, skiprows=header_rows_nb, converters=np_converters
313
- )
526
+ data = np.loadtxt(file_name, delimiter=dialect.delimiter, usecols=columns, skiprows=header_rows_nb, converters=np_converters)
314
527
 
315
528
  except Exception:
316
529
  return False, f"{sys.exc_info()[1]}", np.array([])
@@ -322,7 +535,7 @@ def txt2np_array(
322
535
  return True, "", data
323
536
 
324
537
 
325
- def versiontuple(version_str: str):
538
+ def versiontuple(version_str: str) -> tuple:
326
539
  """
327
540
  Convert version from str to tuple of str
328
541
 
@@ -338,9 +551,34 @@ def versiontuple(version_str: str):
338
551
  return tuple(filled)
339
552
 
340
553
 
554
+ def behavior_user_color(ethogram: dict, behavior_code: str) -> Union[str, None]:
555
+ """
556
+ returns the color of behavior if defined else None
557
+ """
558
+ for x in ethogram:
559
+ if ethogram[x][cfg.BEHAVIOR_CODE] == behavior_code:
560
+ if ethogram[x].get(cfg.COLOR, None) == "":
561
+ return None
562
+ else:
563
+ return ethogram[x].get(cfg.COLOR, None)
564
+
565
+ return None
566
+
567
+
568
+ def behav_category_user_color(behavioral_categories: dict, name: str) -> Union[str, None]:
569
+ """
570
+ returns the color of the behavioral category if defined else None
571
+ """
572
+ for key in behavioral_categories:
573
+ if behavioral_categories[key]["name"] == name:
574
+ return behavioral_categories[key].get(cfg.COLOR, None)
575
+
576
+ return None
577
+
578
+
341
579
  def state_behavior_codes(ethogram: dict) -> list:
342
580
  """
343
- behavior codes defined as STATE event
581
+ returns a list of behavior codes defined as STATE event
344
582
 
345
583
  Args:
346
584
  ethogram (dict): ethogram dictionary
@@ -349,12 +587,12 @@ def state_behavior_codes(ethogram: dict) -> list:
349
587
  list: list of behavior codes defined as STATE event
350
588
 
351
589
  """
352
- return [ethogram[x][cfg.BEHAVIOR_CODE] for x in ethogram if cfg.STATE in ethogram[x][cfg.TYPE].upper()]
590
+ return [ethogram[x][cfg.BEHAVIOR_CODE] for x in ethogram if ethogram[x][cfg.TYPE] in cfg.STATE_EVENT_TYPES]
353
591
 
354
592
 
355
593
  def point_behavior_codes(ethogram: dict) -> list:
356
594
  """
357
- behavior codes defined as POINT event
595
+ returns a list of behavior codes defined as POINT event
358
596
 
359
597
  Args:
360
598
  ethogram (dict): ethogram dictionary
@@ -363,7 +601,7 @@ def point_behavior_codes(ethogram: dict) -> list:
363
601
  list: list of behavior codes defined as POINT event
364
602
 
365
603
  """
366
- return [ethogram[x][cfg.BEHAVIOR_CODE] for x in ethogram if cfg.POINT in ethogram[x][cfg.TYPE].upper()]
604
+ return [ethogram[x][cfg.BEHAVIOR_CODE] for x in ethogram if ethogram[x][cfg.TYPE] in (cfg.POINT_EVENT, cfg.POINT_EVENT_WITH_CODING_MAP)]
367
605
 
368
606
 
369
607
  def group_events(pj: dict, obs_id: str, include_modifiers: bool = False) -> dict:
@@ -388,7 +626,6 @@ def group_events(pj: dict, obs_id: str, include_modifiers: bool = False) -> dict
388
626
  intervals_behav = {}
389
627
 
390
628
  for event in pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS]:
391
-
392
629
  time_ = event[cfg.EVENT_TIME_FIELD_IDX]
393
630
  subject = event[cfg.EVENT_SUBJECT_FIELD_IDX]
394
631
  code = event[cfg.EVENT_BEHAVIOR_FIELD_IDX]
@@ -396,9 +633,7 @@ def group_events(pj: dict, obs_id: str, include_modifiers: bool = False) -> dict
396
633
 
397
634
  # check if code is state
398
635
  if code in state_events_list:
399
-
400
636
  if (subject, code, modifier) in mem_behav and mem_behav[(subject, code, modifier)]:
401
-
402
637
  if (subject, code, modifier) not in intervals_behav:
403
638
  intervals_behav[(subject, code, modifier)] = []
404
639
  intervals_behav[(subject, code, modifier)].append((mem_behav[(subject, code, modifier)], time_))
@@ -419,8 +654,21 @@ def group_events(pj: dict, obs_id: str, include_modifiers: bool = False) -> dict
419
654
  return {"error": ""}
420
655
 
421
656
 
657
+ def flatten_list(nested_list) -> list:
658
+ """
659
+ Flatten a list of lists.
660
+ """
661
+ flattened: list = []
662
+ for item in nested_list:
663
+ if isinstance(item, list):
664
+ flattened.extend(flatten_list(item))
665
+ else:
666
+ flattened.append(item)
667
+ return flattened
668
+
669
+
422
670
  def get_current_states_modifiers_by_subject(
423
- state_behaviors_codes: list, events: list, subjects: dict, time: dec, include_modifiers: bool = False
671
+ state_behaviors_codes: list, events: list, subjects: dict, time_: dec, include_modifiers: bool = False
424
672
  ) -> dict:
425
673
  """
426
674
  get current states and modifiers (if requested) for subjects at given time
@@ -436,7 +684,7 @@ def get_current_states_modifiers_by_subject(
436
684
  dict: current states by subject. dict of list
437
685
  """
438
686
  current_states: dict = {}
439
- if time.is_nan():
687
+ if time_.is_nan():
440
688
  for idx in subjects:
441
689
  current_states[idx] = []
442
690
  return current_states
@@ -449,43 +697,45 @@ def get_current_states_modifiers_by_subject(
449
697
 
450
698
  if include_modifiers:
451
699
  for idx in subjects:
452
- current_states[idx] = []
453
- for sbc in state_behaviors_codes:
454
- bl = [
700
+ current_states[subjects[idx]["name"]] = {}
701
+ for x in events:
702
+ if x[check_index] > time_:
703
+ break
704
+ if x[cfg.EVENT_BEHAVIOR_FIELD_IDX] in state_behaviors_codes:
705
+ if (x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX]) not in current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]]:
706
+ current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][(x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])] = (
707
+ False
708
+ )
709
+
710
+ current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][
455
711
  (x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])
456
- for x in events
457
- if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx][cfg.SUBJECT_NAME]
458
- and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
459
- and x[check_index] <= time
460
- ]
712
+ ] = not current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][(x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])]
461
713
 
462
- if len(bl) % 2: # test if odd
463
- current_states[idx].append(bl[-1][0] + f" ({bl[-1][1]})" * (bl[-1][1] != ""))
714
+ r: dict = {}
715
+ for idx in subjects:
716
+ r[idx] = [f"{bm[0]} ({bm[1]})" for bm in current_states[subjects[idx]["name"]] if current_states[subjects[idx]["name"]][bm]]
464
717
 
465
718
  else:
466
719
  for idx in subjects:
467
- current_states[idx] = []
468
- for sbc in state_behaviors_codes:
469
- if (
470
- len(
471
- [
472
- x[cfg.EVENT_BEHAVIOR_FIELD_IDX]
473
- for x in events
474
- if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx][cfg.SUBJECT_NAME]
475
- and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
476
- and x[check_index] <= time
477
- ]
478
- )
479
- % 2
480
- ): # test if odd
481
- current_states[idx].append(sbc)
720
+ current_states[subjects[idx]["name"]] = {}
721
+ for b in state_behaviors_codes:
722
+ current_states[subjects[idx]["name"]][b] = False
723
+ for x in events:
724
+ if x[check_index] > time_:
725
+ break
726
+ if x[cfg.EVENT_BEHAVIOR_FIELD_IDX] in state_behaviors_codes:
727
+ current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][x[cfg.EVENT_BEHAVIOR_FIELD_IDX]] = not current_states[
728
+ x[cfg.EVENT_SUBJECT_FIELD_IDX]
729
+ ][x[cfg.EVENT_BEHAVIOR_FIELD_IDX]]
730
+
731
+ r: dict = {}
732
+ for idx in subjects:
733
+ r[idx] = [b for b in state_behaviors_codes if current_states[subjects[idx]["name"]][b]]
482
734
 
483
- return current_states
735
+ return r
484
736
 
485
737
 
486
- def get_current_states_modifiers_by_subject_2(
487
- state_behaviors_codes: list, events: list, subjects: dict, time: dec
488
- ) -> dict:
738
+ def get_current_states_modifiers_by_subject_2(state_behaviors_codes: list, events: list, subjects: dict, time: dec) -> dict:
489
739
  """
490
740
  get current states and modifiers for subjects at given time
491
741
  differs from get_current_states_modifiers_by_subject in the output format: [behavior, modifiers]
@@ -495,7 +745,6 @@ def get_current_states_modifiers_by_subject_2(
495
745
  events (list): list of events
496
746
  subjects (dict): dictionary of subjects
497
747
  time (Decimal): time
498
- include_modifiers (bool): include modifier if True (default: False)
499
748
 
500
749
  Returns:
501
750
  dict: current states by subject. dict of list
@@ -549,7 +798,8 @@ def get_current_points_by_subject(
549
798
  point_events = [
550
799
  (x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])
551
800
  for x in events
552
- if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx]["name"] and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
801
+ if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx]["name"]
802
+ and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
553
803
  # and abs(x[EVENT_TIME_FIELD_IDX] - time) <= tolerance
554
804
  and time <= x[cfg.EVENT_TIME_FIELD_IDX] < (time + tolerance)
555
805
  ]
@@ -566,19 +816,6 @@ def get_current_points_by_subject(
566
816
  return current_points
567
817
 
568
818
 
569
- def get_ip_address():
570
- """Get current IP address
571
-
572
- Args:
573
-
574
- Returns:
575
- str: IP address
576
- """
577
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
578
- s.connect(("8.8.8.8", 80))
579
- return s.getsockname()[0]
580
-
581
-
582
819
  def check_txt_file(file_name: str) -> dict:
583
820
  """
584
821
  Extract parameters of txt file (test for tsv csv)
@@ -598,7 +835,7 @@ def check_txt_file(file_name: str) -> dict:
598
835
  buff = csvfile.read(4096)
599
836
  snif = csv.Sniffer()
600
837
  dialect = snif.sniff(buff)
601
- """has_header = snif.has_header(buff)"""
838
+ has_header = snif.has_header(buff)
602
839
 
603
840
  csv.register_dialect("dialect", dialect)
604
841
  rows_len: list = []
@@ -628,6 +865,7 @@ def check_txt_file(file_name: str) -> dict:
628
865
  "fields number": rows_len[0],
629
866
  "separator": dialect.delimiter,
630
867
  "rows number": rows_number,
868
+ "has header": has_header,
631
869
  }
632
870
  else:
633
871
  return {"homogeneous": False}
@@ -647,15 +885,15 @@ def extract_wav(ffmpeg_bin: str, media_file_path: str, tmp_dir: str) -> str:
647
885
  str: wav file path or "" if error
648
886
  """
649
887
 
650
- wav_file_path = pl.Path(tmp_dir) / pl.Path(media_file_path + ".wav").name
888
+ wav_file_path = Path(tmp_dir) / Path(media_file_path + ".wav").name
651
889
 
652
890
  # check if media file is a wav file
653
891
  try:
654
892
  wav = wave.open(media_file_path, "r")
655
893
  wav.close()
656
- logging.debug(f"{media_file_path} is a WAV file. Copying in the temp directory...")
894
+ logger.debug(f"{media_file_path} is a WAV file. Copying in the temp directory...")
657
895
  copyfile(media_file_path, wav_file_path)
658
- logging.debug(f"{media_file_path} copied in {wav_file_path}")
896
+ logger.debug(f"{media_file_path} copied in {wav_file_path}")
659
897
  return str(wav_file_path)
660
898
  except Exception:
661
899
  if wav_file_path.is_file():
@@ -670,7 +908,7 @@ def extract_wav(ffmpeg_bin: str, media_file_path: str, tmp_dir: str) -> str:
670
908
  )
671
909
  out, error = p.communicate()
672
910
  out, error = out.decode("utf-8"), error.decode("utf-8")
673
- logging.debug(f"{out}, {error}")
911
+ logger.debug(f"{out}, {error}")
674
912
 
675
913
  if "does not contain any stream" not in error:
676
914
  if wav_file_path.is_file():
@@ -686,40 +924,46 @@ def decimal_default(obj):
686
924
  raise TypeError
687
925
 
688
926
 
689
- def complete(l: list, max_: int) -> list:
927
+ def complete(lst: list, max_: int) -> list:
690
928
  """
691
929
  complete list with empty string ("") until len = max
692
930
 
693
931
  Args:
694
- l (list): list to complete
932
+ lst (list): list to complete
695
933
  max_ (int): number of items to reach
696
934
 
697
935
  Returns:
698
936
  list: list completed to max_ items with empty string ("")
699
937
  """
700
- while len(l) < max_:
701
- l.append("")
702
- # l.extend([""] * (max_ - len(l)))
703
- return l
938
+ while len(lst) < max_:
939
+ lst.append("")
940
+ return lst
704
941
 
705
942
 
706
943
  def datetime_iso8601(dt) -> str:
707
944
  """
708
- current date time in ISO8601 format without milliseconds
945
+ current date time in ISO8601 format without microseconds
709
946
  example: 2019-06-13 10:01:02
710
947
 
711
948
  Returns:
712
- str: date time in ISO8601 format
949
+ str: date time in ISO8601 format without microseconds
713
950
  """
714
- return dt.isoformat(" ").split(".")[0]
951
+ return dt.isoformat(sep=" ", timespec="seconds")
715
952
 
716
953
 
717
- def seconds_of_day(dt) -> dec:
954
+ def seconds_of_day(timestamp: dt.datetime) -> dec:
718
955
  """
719
956
  return the number of seconds since start of the day
957
+
958
+ Returns:
959
+ dev: number of seconds since the start of the day
720
960
  """
721
961
 
722
- return dec((dt - datetime.datetime.combine(dt.date(), datetime.time(0))).total_seconds()).quantize(dec("0.001"))
962
+ # logger.debug("function: seconds_of_day")
963
+ # logger.debug(f"{timestamp = }")
964
+
965
+ t = timestamp.time()
966
+ return dec(t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1000000).quantize(dec("0.001"))
723
967
 
724
968
 
725
969
  def sorted_keys(d: dict) -> list:
@@ -749,7 +993,7 @@ def intfloatstr(s: str) -> int:
749
993
  return s
750
994
 
751
995
 
752
- def distance(p1, p2):
996
+ def distance(p1: tuple, p2: tuple) -> float:
753
997
  """
754
998
  euclidean distance between 2 points
755
999
  """
@@ -781,6 +1025,62 @@ def angle(vertex: tuple, side1: tuple, side2: tuple) -> float:
781
1025
  )
782
1026
 
783
1027
 
1028
+ def oriented_angle(P1: tuple, P2: tuple, P3: tuple) -> float:
1029
+ """
1030
+ Calculate the oriented angle between two segments.
1031
+
1032
+ Args:
1033
+ P1 (tuple): Coordinates of the vertex
1034
+ P2 (tuple): Coordinates of the first point
1035
+ P3 (tuple): Coordinates of the second point
1036
+
1037
+ Returns:
1038
+ float: The oriented angle between the two segments in degrees.
1039
+ """
1040
+
1041
+ x1, y1 = P1
1042
+ x2, y2 = P2
1043
+ x3, y3 = P1
1044
+ x4, y4 = P3
1045
+
1046
+ angle_AB = math.atan2(y2 - y1, x2 - x1)
1047
+ angle_CD = math.atan2(y4 - y3, x4 - x3)
1048
+
1049
+ oriented_angle = math.degrees(angle_AB - angle_CD)
1050
+
1051
+ return oriented_angle
1052
+
1053
+
1054
+ def oriented_angle_trigo(B: Tuple[float, float], A: Tuple[float, float], C: Tuple[float, float]) -> float:
1055
+ """
1056
+ Calculates the oriented angle between vectors BA and BC, in degrees.
1057
+ The angle is positive in the counter-clockwise (trigonometric) direction.
1058
+
1059
+ Parameters:
1060
+ B: The pivot point (the origin of the vectors BA and BC).
1061
+ A, C: Points that define the vectors.
1062
+
1063
+ Returns:
1064
+ Angle in degrees, between 0 and 360.
1065
+ """
1066
+ # Vectors BA and BC
1067
+ v1 = (A[0] - B[0], A[1] - B[1])
1068
+ v2 = (C[0] - B[0], C[1] - B[1])
1069
+
1070
+ # Dot product and 2D cross product (determinant)
1071
+ dot = v1[0] * v2[0] + v1[1] * v2[1]
1072
+ det = v1[0] * v2[1] - v1[1] * v2[0]
1073
+
1074
+ # Signed angle in radians, then converted to degrees
1075
+ angle_rad = math.atan2(det, dot)
1076
+ angle_deg = math.degrees(angle_rad)
1077
+
1078
+ if angle_deg < 0:
1079
+ angle_deg += 360
1080
+
1081
+ return angle_deg
1082
+
1083
+
784
1084
  def mem_info():
785
1085
  """
786
1086
  get info about total mem, used mem and available mem using:
@@ -798,9 +1098,7 @@ def mem_info():
798
1098
  process = subprocess.run(["free", "-m"], stdout=subprocess.PIPE)
799
1099
  # out, err = process.communicate()
800
1100
  out = process.stdout
801
- _, tot_mem, used_mem, _, _, _, available_mem = [
802
- x.decode("utf-8") for x in out.split(b"\n")[1].split(b" ") if x != b""
803
- ]
1101
+ _, tot_mem, used_mem, _, _, _, available_mem = [x.decode("utf-8") for x in out.split(b"\n")[1].split(b" ") if x != b""]
804
1102
  return False, {
805
1103
  "total_memory": int(tot_mem),
806
1104
  "used_memory": int(used_mem),
@@ -819,11 +1117,8 @@ def mem_info():
819
1117
  return True, {"msg": error_info(sys.exc_info())[0]}
820
1118
 
821
1119
  if sys.platform.startswith("win"):
822
-
823
1120
  try:
824
- output = subprocess.run(
825
- ["wmic", "computersystem", "get", "TotalPhysicalMemory", "/", "Value"], stdout=subprocess.PIPE
826
- )
1121
+ output = subprocess.run(["wmic", "computersystem", "get", "TotalPhysicalMemory", "/", "Value"], stdout=subprocess.PIPE)
827
1122
  tot_mem = int(output.stdout.strip().split(b"=")[-1].decode("utf-8")) / 1024 / 1024
828
1123
 
829
1124
  output = subprocess.run(["wmic", "OS", "get", "FreePhysicalMemory", "/", "Value"], stdout=subprocess.PIPE)
@@ -836,7 +1131,7 @@ def mem_info():
836
1131
  return True, {"msg": "Unknown operating system"}
837
1132
 
838
1133
 
839
- def polygon_area(poly):
1134
+ def polygon_area(poly: list) -> float:
840
1135
  """
841
1136
  area of polygon
842
1137
  from http://www.mathopenref.com/coordpolygonarea.html
@@ -851,7 +1146,20 @@ def polygon_area(poly):
851
1146
  return abs(tot / 2)
852
1147
 
853
1148
 
854
- def url2path(url):
1149
+ def polyline_length(poly: list) -> float:
1150
+ """
1151
+ length of polyline
1152
+ """
1153
+ tot = 0
1154
+ for p in range(1, len(poly)):
1155
+ x1, y1 = poly[p - 1]
1156
+ x2, y2 = poly[p]
1157
+ tot += ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5
1158
+
1159
+ return tot
1160
+
1161
+
1162
+ def url2path(url: str) -> str:
855
1163
  """
856
1164
  convert URL in local path name
857
1165
  under windows, check if path name begin with /
@@ -882,14 +1190,20 @@ def time2seconds(time_: str) -> dec:
882
1190
  Decimal: time in seconds
883
1191
  """
884
1192
 
885
- try:
886
- flag_neg = "-" in time_
887
- time_ = time_.replace("-", "")
888
- tsplit = time_.split(":")
889
- h, m, s = int(tsplit[0]), int(tsplit[1]), dec(tsplit[2])
890
- return dec(-(h * 3600 + m * 60 + s)) if flag_neg else dec(h * 3600 + m * 60 + s)
891
- except Exception:
892
- return dec("0.000")
1193
+ if " " in time_:
1194
+ try:
1195
+ return dec(str(dt.datetime.strptime(time_, "%Y-%m-%d %H:%M:%S.%f").timestamp()))
1196
+ except Exception:
1197
+ return dec("0.000")
1198
+ else:
1199
+ try:
1200
+ flag_neg = "-" in time_
1201
+ time_ = time_.replace("-", "")
1202
+ tsplit = time_.split(":")
1203
+ h, m, s = int(tsplit[0]), int(tsplit[1]), dec(tsplit[2])
1204
+ return dec(-(h * 3600 + m * 60 + s)) if flag_neg else dec(h * 3600 + m * 60 + s)
1205
+ except Exception:
1206
+ return dec("0.000")
893
1207
 
894
1208
 
895
1209
  def seconds2time(sec: dec) -> str:
@@ -905,8 +1219,9 @@ def seconds2time(sec: dec) -> str:
905
1219
  if math.isnan(sec):
906
1220
  return cfg.NA
907
1221
 
908
- if sec > 1_600_000_000: # epoch time
909
- t = datetime.datetime.fromtimestamp(float(sec))
1222
+ # if sec > one day treat as date
1223
+ if sec > cfg.DATE_CUTOFF:
1224
+ t = dt.datetime.fromtimestamp(float(sec))
910
1225
  return f"{t:%Y-%m-%d %H:%M:%S}.{t.microsecond / 1000:03.0f}"
911
1226
 
912
1227
  neg_sign = "-" * (sec < 0)
@@ -965,7 +1280,7 @@ def eol2space(s: str) -> str:
965
1280
  return s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
966
1281
 
967
1282
 
968
- def test_ffmpeg_path(FFmpegPath):
1283
+ def test_ffmpeg_path(FFmpegPath: str) -> Tuple[bool, str]:
969
1284
  """
970
1285
  test if ffmpeg has valid path
971
1286
 
@@ -977,22 +1292,20 @@ def test_ffmpeg_path(FFmpegPath):
977
1292
  str: message
978
1293
  """
979
1294
 
980
- out, error = subprocess.Popen(
981
- f'"{FFmpegPath}" -version', stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
982
- ).communicate()
983
- logging.debug(f"test ffmpeg path output: {out}")
984
- logging.debug(f"test ffmpeg path error: {error}")
1295
+ out, error = subprocess.Popen(f'"{FFmpegPath}" -version', stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True).communicate()
1296
+ logger.debug(f"test ffmpeg path output: {out}")
1297
+ logger.debug(f"test ffmpeg path error: {error}")
985
1298
 
986
1299
  if (b"avconv" in out) or (b"the Libav developers" in error):
987
1300
  return False, "Please use FFmpeg from https://www.ffmpeg.org in place of FFmpeg from Libav project."
988
1301
 
989
1302
  if (b"ffmpeg version" not in out) and (b"ffmpeg version" not in error):
990
- return False, "FFmpeg is required but it was not found...<br>See https://www.ffmpeg.org"
1303
+ return False, "FFmpeg is required but it was not found.<br>See https://www.ffmpeg.org"
991
1304
 
992
1305
  return True, ""
993
1306
 
994
1307
 
995
- def check_ffmpeg_path():
1308
+ def check_ffmpeg_path() -> Tuple[bool, str]:
996
1309
  """
997
1310
  check for ffmpeg path
998
1311
  firstly search for embedded version
@@ -1003,45 +1316,24 @@ def check_ffmpeg_path():
1003
1316
  str: if bool True returns ffmpegpath else returns error message
1004
1317
  """
1005
1318
 
1319
+ # search embedded ffmpeg
1006
1320
  if sys.platform.startswith("linux") or sys.platform.startswith("darwin"):
1321
+ ffmpeg_executable = Path("ffmpeg")
1322
+ elif sys.platform.startswith("win"):
1323
+ ffmpeg_executable = Path("ffmpeg.exe")
1007
1324
 
1008
- ffmpeg_path = pl.Path("")
1009
- # search embedded ffmpeg
1010
- if sys.argv[0].endswith("start_boris.py"):
1011
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "boris" / "misc" / "ffmpeg"
1012
- if sys.argv[0].endswith("__main__.py"):
1013
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "misc" / "ffmpeg"
1014
-
1015
- if not ffmpeg_path.is_file():
1016
- # search global ffmpeg
1017
- ffmpeg_path = "ffmpeg"
1018
-
1019
- # test ffmpeg
1020
- r, msg = test_ffmpeg_path(str(ffmpeg_path))
1021
- if r:
1022
- return True, str(ffmpeg_path)
1023
- else:
1024
- return False, "FFmpeg is not available"
1325
+ ffmpeg_path = Path(__file__).parent / "misc" / ffmpeg_executable
1025
1326
 
1026
- if sys.platform.startswith("win"):
1327
+ if not ffmpeg_path.is_file():
1328
+ # search global ffmpeg
1329
+ ffmpeg_path = ffmpeg_executable
1027
1330
 
1028
- ffmpeg_path = pl.Path("")
1029
- # search embedded ffmpeg
1030
- if sys.argv[0].endswith("start_boris.py"):
1031
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "boris" / "misc" / "ffmpeg.exe"
1032
- if sys.argv[0].endswith("__main__.py"):
1033
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "misc" / "ffmpeg.exe"
1034
-
1035
- if not ffmpeg_path.is_file():
1036
- # search global ffmpeg
1037
- ffmpeg_path = "ffmpeg"
1038
-
1039
- # test ffmpeg
1040
- r, msg = test_ffmpeg_path(str(ffmpeg_path))
1041
- if r:
1042
- return True, str(ffmpeg_path)
1043
- else:
1044
- return False, "FFmpeg is not available"
1331
+ # test ffmpeg
1332
+ r, msg = test_ffmpeg_path(str(ffmpeg_path))
1333
+ if r:
1334
+ return True, str(ffmpeg_path)
1335
+ else:
1336
+ return False, "FFmpeg is not available"
1045
1337
 
1046
1338
 
1047
1339
  def smart_size_format(n: Union[float, int, str, None]) -> str:
@@ -1063,6 +1355,73 @@ def smart_size_format(n: Union[float, int, str, None]) -> str:
1063
1355
  return f"{n / 1_000_000_000:,.1f} Gb"
1064
1356
 
1065
1357
 
1358
+ def get_systeminfo() -> str:
1359
+ """
1360
+ returns info about the system
1361
+ """
1362
+
1363
+ mpv_lib_version_, mpv_lib_file_path, mpv_api_version = mpv_lib_version()
1364
+
1365
+ system_info = (
1366
+ f"BORIS version: {version.__version__}\n"
1367
+ f"OS: {platform.uname().system} {platform.uname().release} {platform.uname().version}\n"
1368
+ f"CPU: {platform.uname().machine} {platform.uname().processor}\n"
1369
+ f"Python {platform.python_version()} ({'64-bit' if sys.maxsize > 2**32 else '32-bit'})\n"
1370
+ f"Qt {qVersion()} - PySide {pyside6_version}\n"
1371
+ f"MPV library version: {mpv_lib_version_}\n"
1372
+ f"MPV API version: {mpv_api_version}\n"
1373
+ f"MPV library file path: {mpv_lib_file_path}\n\n"
1374
+ )
1375
+
1376
+ r, memory = mem_info()
1377
+ if not r:
1378
+ system_info += (
1379
+ f"Memory (RAM) Total: {memory.get('total_memory', 'Not available'):.2f} Mb "
1380
+ f"Free: {memory.get('free_memory', 'Not available'):.2f} Mb\n\n"
1381
+ )
1382
+
1383
+ return system_info
1384
+
1385
+ """
1386
+ # system info
1387
+ systeminfo = ""
1388
+ if sys.platform.startswith("win"):
1389
+ # systeminfo = subprocess.getoutput("systeminfo")
1390
+ systeminfo = subprocess.run("systeminfo /FO csv /NH", capture_output=True, text=True, encoding="mbcs", shell=True).stdout
1391
+
1392
+ import csv
1393
+ from io import StringIO
1394
+
1395
+ # Parse it as CSV
1396
+ f = StringIO(systeminfo)
1397
+ reader = csv.reader(f)
1398
+ parsed_data = list(reader)[0]
1399
+ # Print specific fields by index
1400
+ info_to_show = ""
1401
+ info_to_show += f"Computer Name: {parsed_data[0]}\n"
1402
+ info_to_show += f"OS Name: {parsed_data[1]}\n"
1403
+ info_to_show += f"OS Version: {parsed_data[2]}\n"
1404
+ info_to_show += f"System Manufacturer: {parsed_data[11]}\n"
1405
+ info_to_show += f"System Model: {parsed_data[12]}\n"
1406
+ info_to_show += f"Processor: {parsed_data[14]}\n"
1407
+ info_to_show += f"Locale: {parsed_data[19]}\n"
1408
+ info_to_show += f"Installed Memory: {parsed_data[22]}\n"
1409
+
1410
+ # info about graphic card
1411
+ graphic_info = subprocess.run(
1412
+ "wmic path win32_videocontroller get name", capture_output=True, text=True, encoding="mbcs", shell=True
1413
+ ).stdout
1414
+ info_to_show += graphic_info.replace("\n", "").replace("Name", "Graphic card model")
1415
+
1416
+ systeminfo = info_to_show
1417
+
1418
+ if sys.platform.startswith("linux"):
1419
+ systeminfo = subprocess.getoutput("cat /etc/*rel*; uname -a")
1420
+
1421
+ return systeminfo
1422
+ """
1423
+
1424
+
1066
1425
  def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1067
1426
  """
1068
1427
  analyse video parameters with ffprobe (if available)
@@ -1074,28 +1433,32 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1074
1433
  Returns:
1075
1434
  dict
1076
1435
  """
1077
- # ffprobe -v quiet -print_format json -show_format -show_streams /tmp/ramdisk/video1.mp4
1436
+
1437
+ # check ffprobe executable in same place than ffmpeg
1078
1438
  ffprobe_bin = ffmpeg_bin.replace("ffmpeg", "ffprobe")
1439
+ if not Path(ffprobe_bin).is_file():
1440
+ if which(ffprobe_bin) is None:
1441
+ return {"error": "ffprobe not found"}
1079
1442
 
1080
1443
  command = f'"{ffprobe_bin}" -hide_banner -v error -print_format json -show_format -show_streams "{file_name}"'
1081
1444
 
1082
- # print(command)
1083
-
1084
1445
  p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
1085
1446
  out, error = p.communicate()
1086
1447
  if error:
1087
- return {"error": f"{error}"}
1448
+ if b"invalid data" in error:
1449
+ return {"error": f"{error}"}
1088
1450
 
1089
1451
  try:
1090
1452
  hasVideo = False
1091
1453
  hasAudio = False
1092
- bitrate = None
1454
+ """bitrate = None"""
1093
1455
  video_bitrate = None
1094
1456
  audio_bitrate = []
1095
1457
  resolution = None
1096
- fps = 0
1458
+ fps: float = 0
1097
1459
  sample_rate = None
1098
- duration = 0
1460
+ duration = None
1461
+ audio_duration = cfg.NA
1099
1462
  frames_number = None
1100
1463
  size = None
1101
1464
  audio_codec = None
@@ -1106,7 +1469,6 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1106
1469
  size = int(video_param["format"]["size"])
1107
1470
 
1108
1471
  for stream in video_param["streams"]:
1109
-
1110
1472
  if stream["codec_type"] == "video":
1111
1473
  hasVideo = True
1112
1474
  video_bitrate = int(stream["bit_rate"]) if "bit_rate" in stream else None
@@ -1130,9 +1492,16 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1130
1492
  fps = eval(stream["r_frame_rate"])
1131
1493
  except Exception:
1132
1494
  fps = 0
1495
+ if fps >= 1000 and "avg_frame_rate" in stream: # case for some h265 video ("r_frame_rate": "1200000/1")
1496
+ try:
1497
+ fps = eval(stream["avg_frame_rate"])
1498
+ except Exception:
1499
+ pass
1133
1500
 
1134
- duration = float(stream["duration"])
1135
- # frames_number = int(stream["nb_frames"]) if "nb_frames" in stream else None
1501
+ fps = round(fps, 3)
1502
+
1503
+ if "duration" in stream:
1504
+ duration = float(stream["duration"])
1136
1505
  if "duration_ts" in stream:
1137
1506
  frames_number = int(stream["duration_ts"])
1138
1507
  elif "nb_frames" in stream:
@@ -1144,11 +1513,19 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1144
1513
 
1145
1514
  if stream["codec_type"] == "audio":
1146
1515
  hasAudio = True
1147
- sample_rate = float(stream["sample_rate"])
1148
- duration = float(stream["duration"])
1516
+ sample_rate = float(stream["sample_rate"]) if "sample_rate" in stream else cfg.NA
1517
+ # TODO manage audio_duration parameter
1518
+ audio_duration = float(stream["duration"]) if "duration" in stream else cfg.NA
1149
1519
  audio_codec = stream["codec_long_name"]
1150
1520
  audio_bitrate.append(int(stream.get("bit_rate", 0)))
1151
1521
 
1522
+ # check duration
1523
+ if duration is None:
1524
+ if "duration" in video_param["format"]:
1525
+ duration = float(video_param["format"]["duration"])
1526
+ else:
1527
+ duration = 0
1528
+
1152
1529
  # check bit rate
1153
1530
  if "bit_rate" in video_param["format"]:
1154
1531
  all_bitrate = int(video_param["format"]["bit_rate"])
@@ -1158,11 +1535,22 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1158
1535
  if video_bitrate is None and all_bitrate is not None:
1159
1536
  video_bitrate = all_bitrate - sum(audio_bitrate)
1160
1537
 
1538
+ # extract format long name
1539
+ format_long_name = video_param["format"]["format_long_name"] if "format_long_name" in video_param["format"] else cfg.NA
1540
+
1541
+ # extract creation time ("creation_time": "2023-03-22T16:50:32.000000Z")
1542
+ creation_time = cfg.NA
1543
+ if "tags" in video_param["format"] and "creation_time" in video_param["format"]["tags"]:
1544
+ creation_time = video_param["format"]["tags"]["creation_time"].replace("T", " ")
1545
+ if "." in creation_time:
1546
+ creation_time = creation_time.split(".")[0]
1547
+
1161
1548
  return {
1162
1549
  "analysis_program": "ffprobe",
1163
1550
  "frames_number": frames_number,
1164
1551
  "duration_ms": duration * 1000,
1165
1552
  "duration": duration,
1553
+ "audio_duration": audio_duration,
1166
1554
  "fps": fps,
1167
1555
  "has_video": hasVideo,
1168
1556
  "has_audio": hasAudio,
@@ -1172,6 +1560,8 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1172
1560
  "file size": size,
1173
1561
  "audio_codec": audio_codec,
1174
1562
  "video_codec": video_codec,
1563
+ "creation_time": creation_time,
1564
+ "format_long_name": format_long_name,
1175
1565
  }
1176
1566
 
1177
1567
  except Exception as e:
@@ -1180,7 +1570,7 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1180
1570
 
1181
1571
  def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1182
1572
  """
1183
- analyse frame rate and video duration with ffmpeg
1573
+ analyse frame rate and video duration with ffprobe or ffmpeg if ffprobe not available
1184
1574
  Returns parameters: duration, duration_ms, bitrate, frames_number, fps, has_video (True/False), has_audio (True/False)
1185
1575
 
1186
1576
  Args:
@@ -1194,13 +1584,14 @@ def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1194
1584
 
1195
1585
  ffprobe_results = ffprobe_media_analysis(ffmpeg_bin, file_name)
1196
1586
 
1587
+ logger.debug(f"file_name: {file_name}")
1588
+ logger.debug(f"ffprobe_results: {ffprobe_results}")
1589
+
1197
1590
  if ("error" not in ffprobe_results) and (ffprobe_results["bitrate"] is not None):
1198
1591
  return ffprobe_results
1199
1592
  else:
1200
1593
  # use ffmpeg
1201
- command = (
1202
- f'"{ffmpeg_bin}" -hide_banner -i "{file_name}" > {"NUL" if sys.platform.startswith("win") else "/dev/null"}'
1203
- )
1594
+ command = f'"{ffmpeg_bin}" -hide_banner -i "{file_name}" > {"NUL" if sys.platform.startswith("win") else "/dev/null"}'
1204
1595
 
1205
1596
  p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
1206
1597
 
@@ -1258,7 +1649,7 @@ def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1258
1649
  if b"Stream #" in row and b"Video:" in row:
1259
1650
  hasVideo = True
1260
1651
  # get resolution \d{3,5}x\d{3,5}
1261
- re_results = re.search(b"\d{3,5}x\d{3,5}", row, re.IGNORECASE)
1652
+ re_results = re.search(r"\d{3,5}x\d{3,5}", row, re.IGNORECASE)
1262
1653
  if re_results:
1263
1654
  resolution = re_results.group(0).decode("utf-8")
1264
1655
  break
@@ -1283,11 +1674,13 @@ def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1283
1674
  "frames_number": int(fps * duration),
1284
1675
  "duration_ms": duration * 1000,
1285
1676
  "duration": duration,
1677
+ "audio_duration": cfg.NA,
1286
1678
  "fps": fps,
1287
1679
  "has_video": hasVideo,
1288
1680
  "has_audio": hasAudio,
1289
1681
  "bitrate": bitrate,
1290
1682
  "resolution": resolution,
1683
+ "format_long_name": "",
1291
1684
  }
1292
1685
 
1293
1686
 
@@ -1308,7 +1701,7 @@ def behavior_color(colors_list: list, idx: int, default_color: str = "darkgray")
1308
1701
  """
1309
1702
 
1310
1703
  try:
1311
- return colors_list[idx % len(colors_list)]
1704
+ return colors_list[idx % len(colors_list)].replace("tab:", "")
1312
1705
  except Exception:
1313
1706
  return default_color
1314
1707
 
@@ -1328,12 +1721,37 @@ def all_behaviors(ethogram: dict) -> list:
1328
1721
  return [ethogram[x][cfg.BEHAVIOR_CODE] for x in sorted_keys(ethogram)]
1329
1722
 
1330
1723
 
1724
+ def all_subjects(subjects: dict) -> list:
1725
+ """
1726
+ extract all subjects from the subject configuration dictionary
1727
+
1728
+ Args:
1729
+ subject configuration (dict)
1730
+
1731
+ Returns:
1732
+ list: subjects name
1733
+ """
1734
+
1735
+ return [subjects[x][cfg.SUBJECT_NAME] for x in sorted_keys(subjects)]
1736
+
1737
+
1738
+ def has_coding_map(ethogram: dict, behavior_idx: str) -> bool:
1739
+ """
1740
+ check if behavior index has a coding map
1741
+ """
1742
+ if not ethogram.get(behavior_idx, False):
1743
+ return False
1744
+ if not ethogram[behavior_idx].get("coding map", False):
1745
+ return False
1746
+ return True
1747
+
1748
+
1331
1749
  def dir_images_number(dir_path_str: str) -> dict:
1332
1750
  """
1333
1751
  return number of images in dir_path (see cfg.IMAGE_EXTENSIONS)
1334
1752
  """
1335
1753
 
1336
- dir_path = pl.Path(dir_path_str)
1754
+ dir_path = Path(dir_path_str)
1337
1755
  if not dir_path.is_dir():
1338
1756
  return {"error": f"The directory {dir_path_str} does not exists"}
1339
1757
  img_count = 0
@@ -1371,29 +1789,11 @@ def intersection(A, B, C, D):
1371
1789
  ym = slope * xm + intersept
1372
1790
  else:
1373
1791
  xm = (
1374
- (
1375
- xd * xa * yc
1376
- - xd * xb * yc
1377
- - xd * xa * yb
1378
- - xc * xa * yd
1379
- + xc * xa * yb
1380
- + xd * ya * xb
1381
- + xc * xb * yd
1382
- - xc * ya * xb
1383
- )
1792
+ (xd * xa * yc - xd * xb * yc - xd * xa * yb - xc * xa * yd + xc * xa * yb + xd * ya * xb + xc * xb * yd - xc * ya * xb)
1384
1793
  / (-yb * xd + yb * xc + ya * xd - ya * xc + xb * yd - xb * yc - xa * yd + xa * yc)
1385
1794
  ).quantize(dec(".001"), rounding=ROUND_DOWN)
1386
1795
  ym = (
1387
- (
1388
- yb * xc * yd
1389
- - yb * yc * xd
1390
- - ya * xc * yd
1391
- + ya * yc * xd
1392
- - xa * yb * yd
1393
- + xa * yb * yc
1394
- + ya * xb * yd
1395
- - ya * xb * yc
1396
- )
1796
+ (yb * xc * yd - yb * yc * xd - ya * xc * yd + ya * yc * xd - xa * yb * yd + xa * yb * yc + ya * xb * yd - ya * xb * yc)
1397
1797
  / (-yb * xd + yb * xc + ya * xd - ya * xc + xb * yd - xb * yc - xa * yd + xa * yc)
1398
1798
  ).quantize(dec(".001"), rounding=ROUND_DOWN)
1399
1799
 
@@ -1402,16 +1802,7 @@ def intersection(A, B, C, D):
1402
1802
  ymin1, ymax1 = min(ya, yb), max(ya, yb)
1403
1803
  ymin2, ymax2 = min(yc, yd), max(yc, yd)
1404
1804
 
1405
- return (
1406
- xm >= xmin1
1407
- and xm <= xmax1
1408
- and xm >= xmin2
1409
- and xm <= xmax2
1410
- and ym >= ymin1
1411
- and ym <= ymax1
1412
- and ym >= ymin2
1413
- and ym <= ymax2
1414
- )
1805
+ return xm >= xmin1 and xm <= xmax1 and xm >= xmin2 and xm <= xmax2 and ym >= ymin1 and ym <= ymax1 and ym >= ymin2 and ym <= ymax2
1415
1806
 
1416
1807
  except Exception: # for cases xa=xb=xc=xd
1417
1808
  return True