boris-behav-obs 8.16.5__py3-none-any.whl → 9.7.12__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 (126) hide show
  1. boris/__init__.py +1 -1
  2. boris/__main__.py +1 -1
  3. boris/about.py +28 -40
  4. boris/add_modifier.py +88 -80
  5. boris/add_modifier_ui.py +266 -144
  6. boris/advanced_event_filtering.py +23 -29
  7. boris/analysis_plugins/__init__.py +0 -0
  8. boris/analysis_plugins/_export_to_feral.py +225 -0
  9. boris/analysis_plugins/_latency.py +59 -0
  10. boris/analysis_plugins/irr_cohen_kappa.py +109 -0
  11. boris/analysis_plugins/irr_cohen_kappa_with_modifiers.py +112 -0
  12. boris/analysis_plugins/irr_weighted_cohen_kappa.py +157 -0
  13. boris/analysis_plugins/irr_weighted_cohen_kappa_with_modifiers.py +162 -0
  14. boris/analysis_plugins/list_of_dataframe_columns.py +22 -0
  15. boris/analysis_plugins/number_of_occurences.py +22 -0
  16. boris/analysis_plugins/number_of_occurences_by_independent_variable.py +54 -0
  17. boris/analysis_plugins/time_budget.py +61 -0
  18. boris/behav_coding_map_creator.py +235 -236
  19. boris/behavior_binary_table.py +33 -50
  20. boris/behaviors_coding_map.py +17 -18
  21. boris/boris_cli.py +6 -25
  22. boris/cmd_arguments.py +12 -1
  23. boris/coding_pad.py +19 -36
  24. boris/config.py +109 -50
  25. boris/config_file.py +58 -67
  26. boris/connections.py +105 -58
  27. boris/converters.py +13 -37
  28. boris/converters_ui.py +187 -110
  29. boris/cooccurence.py +250 -0
  30. boris/core.py +2174 -1303
  31. boris/core_qrc.py +15892 -10829
  32. boris/core_ui.py +941 -806
  33. boris/db_functions.py +17 -42
  34. boris/dev.py +27 -7
  35. boris/dialog.py +461 -242
  36. boris/duration_widget.py +9 -14
  37. boris/edit_event.py +61 -31
  38. boris/edit_event_ui.py +208 -97
  39. boris/event_operations.py +405 -281
  40. boris/events_cursor.py +25 -17
  41. boris/events_snapshots.py +36 -82
  42. boris/exclusion_matrix.py +4 -9
  43. boris/export_events.py +180 -203
  44. boris/export_observation.py +60 -73
  45. boris/external_processes.py +123 -98
  46. boris/geometric_measurement.py +427 -218
  47. boris/gui_utilities.py +91 -14
  48. boris/image_overlay.py +4 -4
  49. boris/import_observations.py +190 -98
  50. boris/ipc_mpv.py +325 -0
  51. boris/irr.py +20 -57
  52. boris/latency.py +31 -24
  53. boris/measurement_widget.py +14 -18
  54. boris/media_file.py +17 -19
  55. boris/menu_options.py +16 -6
  56. boris/modifier_coding_map_creator.py +1013 -0
  57. boris/modifiers_coding_map.py +7 -9
  58. boris/mpv2.py +128 -35
  59. boris/observation.py +501 -211
  60. boris/observation_operations.py +1037 -393
  61. boris/observation_ui.py +573 -363
  62. boris/observations_list.py +51 -58
  63. boris/otx_parser.py +74 -68
  64. boris/param_panel.py +45 -59
  65. boris/param_panel_ui.py +254 -138
  66. boris/player_dock_widget.py +91 -56
  67. boris/plot_data_module.py +20 -53
  68. boris/plot_events.py +56 -153
  69. boris/plot_events_rt.py +16 -30
  70. boris/plot_spectrogram_rt.py +83 -56
  71. boris/plot_waveform_rt.py +27 -49
  72. boris/plugins.py +468 -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 +307 -123
  80. boris/preferences_ui.py +686 -227
  81. boris/project.py +294 -271
  82. boris/project_functions.py +626 -537
  83. boris/project_import_export.py +204 -213
  84. boris/project_ui.py +673 -441
  85. boris/qrc_boris.py +6 -3
  86. boris/qrc_boris5.py +6 -3
  87. boris/select_modifiers.py +62 -90
  88. boris/select_observations.py +19 -197
  89. boris/select_subj_behav.py +67 -39
  90. boris/state_events.py +51 -33
  91. boris/subjects_pad.py +7 -9
  92. boris/synthetic_time_budget.py +42 -26
  93. boris/time_budget_functions.py +169 -169
  94. boris/time_budget_widget.py +77 -89
  95. boris/transitions.py +41 -41
  96. boris/utilities.py +594 -226
  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 +86 -28
  101. boris/view_df.py +104 -0
  102. boris/view_df_ui.py +75 -0
  103. boris/write_event.py +240 -136
  104. boris_behav_obs-9.7.12.dist-info/METADATA +139 -0
  105. boris_behav_obs-9.7.12.dist-info/RECORD +110 -0
  106. {boris_behav_obs-8.16.5.dist-info → boris_behav_obs-9.7.12.dist-info}/WHEEL +1 -1
  107. boris_behav_obs-9.7.12.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 -37
  112. boris/core.ui +0 -1571
  113. boris/edit_event.ui +0 -233
  114. boris/icons/logo_eye.ico +0 -0
  115. boris/map_creator.py +0 -982
  116. boris/observation.ui +0 -814
  117. boris/param_panel.ui +0 -379
  118. boris/preferences.ui +0 -537
  119. boris/project.ui +0 -1074
  120. boris/vlc_local.py +0 -90
  121. boris_behav_obs-8.16.5.dist-info/LICENSE.TXT +0 -674
  122. boris_behav_obs-8.16.5.dist-info/METADATA +0 -134
  123. boris_behav_obs-8.16.5.dist-info/RECORD +0 -107
  124. boris_behav_obs-8.16.5.dist-info/entry_points.txt +0 -2
  125. {boris → boris_behav_obs-9.7.12.dist-info/licenses}/LICENSE.TXT +0 -0
  126. {boris_behav_obs-8.16.5.dist-info → boris_behav_obs-9.7.12.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,31 +19,252 @@ Copyright 2012-2023 Olivier Friard
19
19
  MA 02110-1301, USA.
20
20
  """
21
21
 
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
22
28
  import csv
23
29
  import datetime
24
- import hashlib
30
+ import datetime as dt
31
+ import exifread
25
32
  import json
26
33
  import logging
27
34
  import math
35
+ import numpy as np
28
36
  import os
29
- import pathlib as pl
37
+ from pathlib import Path
38
+ from PIL.ImageQt import Image
39
+ import platform
30
40
  import re
31
- import socket
41
+ import shutil
32
42
  import subprocess
33
43
  import sys
34
44
  import urllib.parse
45
+ import urllib.request
35
46
  import wave
36
- from decimal import Decimal as dec
37
- from decimal import getcontext, ROUND_DOWN
38
- from shutil import copyfile
39
- from typing import Union, Tuple
47
+ import socket
40
48
 
41
- import numpy as np
42
- from PyQt5.QtGui import QPixmap, QImage
43
-
44
- 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
45
52
 
46
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 is_subdir(a: Path, b: Path) -> bool:
130
+ """
131
+ Return True if directory A is inside directory B.
132
+ """
133
+ a = a.resolve()
134
+ b = b.resolve()
135
+ return a.is_relative_to(b)
136
+
137
+
138
+ def test_mpv_ipc(socket_path: str = cfg.MPV_SOCKET) -> bool:
139
+ """
140
+ test if socket available
141
+ """
142
+ if not os.path.exists(socket_path):
143
+ return False
144
+
145
+ try:
146
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
147
+ client.connect(socket_path)
148
+ client.close()
149
+ return True
150
+ except Exception:
151
+ return False
152
+
153
+
154
+ def extract_exif_DateTimeOriginal(file_path: str) -> int:
155
+ """
156
+ extract the EXIF DateTimeOriginal tag
157
+ return epoch time
158
+ if the tag is not available return -1
159
+
160
+ Args:
161
+ file_path (str): path of the media file
162
+
163
+ Returns:
164
+ int: timestamp
165
+
166
+ """
167
+ try:
168
+ with open(file_path, "rb") as f_in:
169
+ tags = exifread.process_file(f_in, details=False, stop_tag="EXIF DateTimeOriginal")
170
+ if "EXIF DateTimeOriginal" in tags:
171
+ date_time_original = (
172
+ f"{tags['EXIF DateTimeOriginal'].values[:4]}-"
173
+ f"{tags['EXIF DateTimeOriginal'].values[5:7]}-"
174
+ f"{tags['EXIF DateTimeOriginal'].values[8:10]} "
175
+ f"{tags['EXIF DateTimeOriginal'].values.split(' ')[-1]}"
176
+ )
177
+ return int(datetime.datetime.strptime(date_time_original, "%Y-%m-%d %H:%M:%S").timestamp())
178
+ else:
179
+ try:
180
+ # read from file name (YYYY-MM-DD_HHMMSS)
181
+ return int(datetime.datetime.strptime(Path(file_path).stem, "%Y-%m-%d_%H%M%S").timestamp())
182
+ except Exception:
183
+ # read from file name (YYYY-MM-DD_HH:MM:SS)
184
+ return int(datetime.datetime.strptime(Path(file_path).stem, "%Y-%m-%d_%H:%M:%S").timestamp())
185
+
186
+ except Exception:
187
+ return -1
188
+
189
+
190
+ def extract_video_creation_date(file_path: str) -> int | None:
191
+ """
192
+ returns the timestamp of the media creation date time with Hachoir
193
+ """
194
+
195
+ logger.debug(f"extract_video_creation_date for {file_path}")
196
+
197
+ if not Path(file_path).is_file():
198
+ logger.debug(f"{file_path} not found")
199
+ return None
200
+ try:
201
+ parser = createParser(file_path)
202
+ metadata = extractMetadata(parser)
203
+ except Exception:
204
+ return None
205
+
206
+ if metadata.has("creation_date"):
207
+ if metadata.get("creation_date") == datetime.datetime(1904, 1, 1, 0, 0):
208
+ return None
209
+ return metadata.get("creation_date").timestamp()
210
+ else:
211
+ return None
212
+
213
+
214
+ def extract_date_time_from_file_name(file_path: str) -> int | None:
215
+ """
216
+ extract YYYY-MM-DD_HHMMSS or YYYY-MM-DD_HH:MM:SS from file name
217
+ """
218
+
219
+ patterns = (r"\d{4}-\d{2}-\d{2}_\d{6}", r"\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2}")
220
+ for pattern in patterns:
221
+ matches = re.findall(pattern, file_path)
222
+
223
+ if matches:
224
+ if pattern == r"\d{4}-\d{2}-\d{2}_\d{6}":
225
+ logger.debug(
226
+ f"extract_date_time_from_file_name timestamp from {file_path}: {int(datetime.datetime.strptime(matches[0], '%Y-%m-%d_%H%M%S').timestamp())}"
227
+ )
228
+
229
+ return int(datetime.datetime.strptime(matches[0], "%Y-%m-%d_%H%M%S").timestamp())
230
+
231
+ if pattern == r"\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2}":
232
+ logger.debug(
233
+ f"extract_date_time_from_file_name timestamp from {file_path}: {int(datetime.datetime.strptime(matches[0], '%Y-%m-%d_%H:%M:%S').timestamp())}"
234
+ )
235
+
236
+ return int(datetime.datetime.strptime(matches[0], "%Y-%m-%d_%H:%M:%S").timestamp())
237
+
238
+ return None
239
+
240
+
241
+ def mpv_lib_version() -> Tuple[str, str, str]:
242
+ """
243
+ Version of MPV library
244
+
245
+ Returns:
246
+ str: MPV library version
247
+ """
248
+ if ("-i" in sys.argv) or ("--ipc" in sys.argv) or sys.platform.startswith("darwin"):
249
+ return "MPV IPC mode", "", ""
250
+
251
+ mpv_lib_file = None
252
+ if sys.platform.startswith("linux"):
253
+ mpv_lib_file = mpv.sofile
254
+ if sys.platform.startswith("win"):
255
+ mpv_lib_file = mpv.dll
256
+
257
+ return (".".join([str(x) for x in mpv._mpv_client_api_version()]), mpv_lib_file, mpv.MPV_VERSION)
258
+
259
+
260
+ def python_mpv_script_version() -> str:
261
+ """
262
+ version of python-mpv script
263
+ """
264
+ try:
265
+ return mpv.__version__
266
+ except Exception:
267
+ return "Not found"
47
268
 
48
269
 
49
270
  def error_info(exc_info: tuple) -> tuple:
@@ -154,28 +375,7 @@ def return_file_header_footer(file_name: str, file_row_number: int = 0, row_numb
154
375
  return header, footer
155
376
 
156
377
 
157
- def bytes_to_str(b: bytes) -> str:
158
- """
159
- Translate bytes to string.
160
-
161
- Args:
162
- b (bytes): byte to convert
163
-
164
- Returns:
165
- str: converted byte
166
- """
167
-
168
- if isinstance(b, bytes):
169
- fileSystemEncoding = sys.getfilesystemencoding()
170
- # hack for PyInstaller
171
- if fileSystemEncoding is None:
172
- fileSystemEncoding = "UTF-8"
173
- return b.decode(fileSystemEncoding)
174
- else:
175
- return b
176
-
177
-
178
- def convertTime(time_format: str, sec: Union[float, dec]) -> str:
378
+ def convertTime(time_format: str, sec: Union[float, dec]) -> Union[str, None]:
179
379
  """
180
380
  convert time in base at the current format (S or HHMMSS)
181
381
 
@@ -198,10 +398,12 @@ def convertTime(time_format: str, sec: Union[float, dec]) -> str:
198
398
  return None
199
399
 
200
400
 
201
- def dynamic_time_format(sec: Union[float, dec], cutoff: dec, time_format: str) -> str:
401
+ def smart_time_format(sec: Union[float, dec], time_format: str = cfg.S, cutoff: dec = cfg.SMART_TIME_CUTOFF_DEFAULT) -> str:
202
402
  """
403
+ Smart time format
203
404
  returns time in seconds if <= cutoff else in HH:MM:SS.ZZZ format
204
405
  """
406
+ # cutoff = 0 follows the time format selectd by user
205
407
  if cutoff == 0:
206
408
  return convertTime(time_format, sec)
207
409
  if sec <= cutoff:
@@ -222,7 +424,10 @@ def convert_time_to_decimal(pj: dict) -> dict:
222
424
  """
223
425
  for obs_id in pj[cfg.OBSERVATIONS]:
224
426
  if cfg.TIME_OFFSET in pj[cfg.OBSERVATIONS][obs_id]:
225
- pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] = dec(str(pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET]))
427
+ if pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] is not None:
428
+ pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] = dec(str(pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET]))
429
+ else:
430
+ pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET] = dec("0.000")
226
431
  for idx, _ in enumerate(pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS]):
227
432
  pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS][idx][cfg.EVENT_TIME_FIELD_IDX] = dec(
228
433
  pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS][idx][cfg.EVENT_TIME_FIELD_IDX]
@@ -238,20 +443,6 @@ def count_media_file(media_files: dict) -> int:
238
443
  return sum([len(media_files[idx]) for idx in media_files])
239
444
 
240
445
 
241
- def file_content_md5(file_name: str) -> str:
242
- """
243
- returns the MD5 sum of file content
244
- """
245
- hash_md5 = hashlib.md5()
246
- try:
247
- with open(file_name, "rb") as f:
248
- for chunk in iter(lambda: f.read(4096), b""):
249
- hash_md5.update(chunk)
250
- return hash_md5.hexdigest()
251
- except FileNotFoundError:
252
- return ""
253
-
254
-
255
446
  def txt2np_array(
256
447
  file_name: str, columns_str: str, substract_first_value: str, converters=None, column_converter=None
257
448
  ) -> Tuple[bool, str, np.array]:
@@ -289,20 +480,37 @@ def txt2np_array(
289
480
  conv_name = column_converter[column_idx]
290
481
 
291
482
  function = f"""def {conv_name}(INPUT):\n"""
292
- function += """ INPUT = INPUT.decode("utf-8") if isinstance(INPUT, bytes) else INPUT"""
483
+ function += """ INPUT = INPUT.decode("utf-8") if isinstance(INPUT, bytes) else INPUT\n\n"""
293
484
  for line in converters[conv_name]["code"].split("\n"):
294
485
  function += f" {line}\n"
295
486
  function += """ return OUTPUT"""
296
487
 
488
+ print("=============")
489
+ print(function)
490
+ print("=============")
491
+
492
+ import types
493
+
494
+ mod = types.ModuleType("converter_module")
495
+ exec(function, mod.__dict__)
496
+
497
+ """
297
498
  try:
298
499
  exec(function)
299
500
  except Exception:
300
501
  return False, f"error in converter: {sys.exc_info()[1]}", np.array([])
301
502
 
302
- np_converters[column_idx - 1] = locals()[conv_name]
503
+ print(f"{converters=}")
504
+ print(f"{column_converter=}")
505
+ print(locals())
506
+ print(f"{conv_name=}")
507
+ """
508
+
509
+ # np_converters[column_idx - 1] = locals()['conv_name']
510
+ np_converters[column_idx - 1] = getattr(mod, conv_name)
303
511
 
304
512
  else:
305
- return False, f"converter {cfg.converters_param[column_idx]} not found", np.array([])
513
+ return False, f"converter {column_converter[column_idx]} not found", np.array([])
306
514
 
307
515
  # snif txt file
308
516
  try:
@@ -324,9 +532,7 @@ def txt2np_array(
324
532
  return False, f"{sys.exc_info()[1]}", np.array([])
325
533
 
326
534
  try:
327
- data = np.loadtxt(
328
- file_name, delimiter=dialect.delimiter, usecols=columns, skiprows=header_rows_nb, converters=np_converters
329
- )
535
+ data = np.loadtxt(file_name, delimiter=dialect.delimiter, usecols=columns, skiprows=header_rows_nb, converters=np_converters)
330
536
 
331
537
  except Exception:
332
538
  return False, f"{sys.exc_info()[1]}", np.array([])
@@ -338,7 +544,7 @@ def txt2np_array(
338
544
  return True, "", data
339
545
 
340
546
 
341
- def versiontuple(version_str: str):
547
+ def versiontuple(version_str: str) -> tuple:
342
548
  """
343
549
  Convert version from str to tuple of str
344
550
 
@@ -381,7 +587,7 @@ def behav_category_user_color(behavioral_categories: dict, name: str) -> Union[s
381
587
 
382
588
  def state_behavior_codes(ethogram: dict) -> list:
383
589
  """
384
- behavior codes defined as STATE event
590
+ returns a list of behavior codes defined as STATE event
385
591
 
386
592
  Args:
387
593
  ethogram (dict): ethogram dictionary
@@ -390,12 +596,12 @@ def state_behavior_codes(ethogram: dict) -> list:
390
596
  list: list of behavior codes defined as STATE event
391
597
 
392
598
  """
393
- return [ethogram[x][cfg.BEHAVIOR_CODE] for x in ethogram if cfg.STATE in ethogram[x][cfg.TYPE].upper()]
599
+ return [ethogram[x][cfg.BEHAVIOR_CODE] for x in ethogram if ethogram[x][cfg.TYPE] in cfg.STATE_EVENT_TYPES]
394
600
 
395
601
 
396
602
  def point_behavior_codes(ethogram: dict) -> list:
397
603
  """
398
- behavior codes defined as POINT event
604
+ returns a list of behavior codes defined as POINT event
399
605
 
400
606
  Args:
401
607
  ethogram (dict): ethogram dictionary
@@ -404,7 +610,7 @@ def point_behavior_codes(ethogram: dict) -> list:
404
610
  list: list of behavior codes defined as POINT event
405
611
 
406
612
  """
407
- return [ethogram[x][cfg.BEHAVIOR_CODE] for x in ethogram if cfg.POINT in ethogram[x][cfg.TYPE].upper()]
613
+ 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)]
408
614
 
409
615
 
410
616
  def group_events(pj: dict, obs_id: str, include_modifiers: bool = False) -> dict:
@@ -457,8 +663,21 @@ def group_events(pj: dict, obs_id: str, include_modifiers: bool = False) -> dict
457
663
  return {"error": ""}
458
664
 
459
665
 
666
+ def flatten_list(nested_list) -> list:
667
+ """
668
+ Flatten a list of lists.
669
+ """
670
+ flattened: list = []
671
+ for item in nested_list:
672
+ if isinstance(item, list):
673
+ flattened.extend(flatten_list(item))
674
+ else:
675
+ flattened.append(item)
676
+ return flattened
677
+
678
+
460
679
  def get_current_states_modifiers_by_subject(
461
- state_behaviors_codes: list, events: list, subjects: dict, time: dec, include_modifiers: bool = False
680
+ state_behaviors_codes: list, events: list, subjects: dict, time_: dec, include_modifiers: bool = False
462
681
  ) -> dict:
463
682
  """
464
683
  get current states and modifiers (if requested) for subjects at given time
@@ -474,7 +693,7 @@ def get_current_states_modifiers_by_subject(
474
693
  dict: current states by subject. dict of list
475
694
  """
476
695
  current_states: dict = {}
477
- if time.is_nan():
696
+ if time_.is_nan():
478
697
  for idx in subjects:
479
698
  current_states[idx] = []
480
699
  return current_states
@@ -487,43 +706,45 @@ def get_current_states_modifiers_by_subject(
487
706
 
488
707
  if include_modifiers:
489
708
  for idx in subjects:
490
- current_states[idx] = []
491
- for sbc in state_behaviors_codes:
492
- bl = [
709
+ current_states[subjects[idx]["name"]] = {}
710
+ for x in events:
711
+ if x[check_index] > time_:
712
+ break
713
+ if x[cfg.EVENT_BEHAVIOR_FIELD_IDX] in state_behaviors_codes:
714
+ if (x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX]) not in current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]]:
715
+ current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][(x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])] = (
716
+ False
717
+ )
718
+
719
+ current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][
493
720
  (x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])
494
- for x in events
495
- if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx][cfg.SUBJECT_NAME]
496
- and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
497
- and x[check_index] <= time
498
- ]
721
+ ] = not current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][(x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])]
499
722
 
500
- if len(bl) % 2: # test if odd
501
- current_states[idx].append(bl[-1][0] + f" ({bl[-1][1]})" * (bl[-1][1] != ""))
723
+ r: dict = {}
724
+ for idx in subjects:
725
+ r[idx] = [f"{bm[0]} ({bm[1]})" for bm in current_states[subjects[idx]["name"]] if current_states[subjects[idx]["name"]][bm]]
502
726
 
503
727
  else:
504
728
  for idx in subjects:
505
- current_states[idx] = []
506
- for sbc in state_behaviors_codes:
507
- if (
508
- len(
509
- [
510
- x[cfg.EVENT_BEHAVIOR_FIELD_IDX]
511
- for x in events
512
- if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx][cfg.SUBJECT_NAME]
513
- and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
514
- and x[check_index] <= time
515
- ]
516
- )
517
- % 2
518
- ): # test if odd
519
- current_states[idx].append(sbc)
729
+ current_states[subjects[idx]["name"]] = {}
730
+ for b in state_behaviors_codes:
731
+ current_states[subjects[idx]["name"]][b] = False
732
+ for x in events:
733
+ if x[check_index] > time_:
734
+ break
735
+ if x[cfg.EVENT_BEHAVIOR_FIELD_IDX] in state_behaviors_codes:
736
+ current_states[x[cfg.EVENT_SUBJECT_FIELD_IDX]][x[cfg.EVENT_BEHAVIOR_FIELD_IDX]] = not current_states[
737
+ x[cfg.EVENT_SUBJECT_FIELD_IDX]
738
+ ][x[cfg.EVENT_BEHAVIOR_FIELD_IDX]]
739
+
740
+ r: dict = {}
741
+ for idx in subjects:
742
+ r[idx] = [b for b in state_behaviors_codes if current_states[subjects[idx]["name"]][b]]
520
743
 
521
- return current_states
744
+ return r
522
745
 
523
746
 
524
- def get_current_states_modifiers_by_subject_2(
525
- state_behaviors_codes: list, events: list, subjects: dict, time: dec
526
- ) -> dict:
747
+ def get_current_states_modifiers_by_subject_2(state_behaviors_codes: list, events: list, subjects: dict, time: dec) -> dict:
527
748
  """
528
749
  get current states and modifiers for subjects at given time
529
750
  differs from get_current_states_modifiers_by_subject in the output format: [behavior, modifiers]
@@ -533,7 +754,6 @@ def get_current_states_modifiers_by_subject_2(
533
754
  events (list): list of events
534
755
  subjects (dict): dictionary of subjects
535
756
  time (Decimal): time
536
- include_modifiers (bool): include modifier if True (default: False)
537
757
 
538
758
  Returns:
539
759
  dict: current states by subject. dict of list
@@ -587,7 +807,8 @@ def get_current_points_by_subject(
587
807
  point_events = [
588
808
  (x[cfg.EVENT_BEHAVIOR_FIELD_IDX], x[cfg.EVENT_MODIFIER_FIELD_IDX])
589
809
  for x in events
590
- if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx]["name"] and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
810
+ if x[cfg.EVENT_SUBJECT_FIELD_IDX] == subjects[idx]["name"]
811
+ and x[cfg.EVENT_BEHAVIOR_FIELD_IDX] == sbc
591
812
  # and abs(x[EVENT_TIME_FIELD_IDX] - time) <= tolerance
592
813
  and time <= x[cfg.EVENT_TIME_FIELD_IDX] < (time + tolerance)
593
814
  ]
@@ -604,19 +825,6 @@ def get_current_points_by_subject(
604
825
  return current_points
605
826
 
606
827
 
607
- def get_ip_address():
608
- """Get current IP address
609
-
610
- Args:
611
-
612
- Returns:
613
- str: IP address
614
- """
615
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
616
- s.connect(("8.8.8.8", 80))
617
- return s.getsockname()[0]
618
-
619
-
620
828
  def check_txt_file(file_name: str) -> dict:
621
829
  """
622
830
  Extract parameters of txt file (test for tsv csv)
@@ -686,15 +894,15 @@ def extract_wav(ffmpeg_bin: str, media_file_path: str, tmp_dir: str) -> str:
686
894
  str: wav file path or "" if error
687
895
  """
688
896
 
689
- wav_file_path = pl.Path(tmp_dir) / pl.Path(media_file_path + ".wav").name
897
+ wav_file_path = Path(tmp_dir) / Path(media_file_path + ".wav").name
690
898
 
691
899
  # check if media file is a wav file
692
900
  try:
693
901
  wav = wave.open(media_file_path, "r")
694
902
  wav.close()
695
- logging.debug(f"{media_file_path} is a WAV file. Copying in the temp directory...")
903
+ logger.debug(f"{media_file_path} is a WAV file. Copying in the temp directory...")
696
904
  copyfile(media_file_path, wav_file_path)
697
- logging.debug(f"{media_file_path} copied in {wav_file_path}")
905
+ logger.debug(f"{media_file_path} copied in {wav_file_path}")
698
906
  return str(wav_file_path)
699
907
  except Exception:
700
908
  if wav_file_path.is_file():
@@ -709,7 +917,7 @@ def extract_wav(ffmpeg_bin: str, media_file_path: str, tmp_dir: str) -> str:
709
917
  )
710
918
  out, error = p.communicate()
711
919
  out, error = out.decode("utf-8"), error.decode("utf-8")
712
- logging.debug(f"{out}, {error}")
920
+ logger.debug(f"{out}, {error}")
713
921
 
714
922
  if "does not contain any stream" not in error:
715
923
  if wav_file_path.is_file():
@@ -743,21 +951,28 @@ def complete(lst: list, max_: int) -> list:
743
951
 
744
952
  def datetime_iso8601(dt) -> str:
745
953
  """
746
- current date time in ISO8601 format without milliseconds
954
+ current date time in ISO8601 format without microseconds
747
955
  example: 2019-06-13 10:01:02
748
956
 
749
957
  Returns:
750
- str: date time in ISO8601 format
958
+ str: date time in ISO8601 format without microseconds
751
959
  """
752
- return dt.isoformat(" ").split(".")[0]
960
+ return dt.isoformat(sep=" ", timespec="seconds")
753
961
 
754
962
 
755
- def seconds_of_day(dt) -> dec:
963
+ def seconds_of_day(timestamp: dt.datetime) -> dec:
756
964
  """
757
965
  return the number of seconds since start of the day
966
+
967
+ Returns:
968
+ dev: number of seconds since the start of the day
758
969
  """
759
970
 
760
- return dec((dt - datetime.datetime.combine(dt.date(), datetime.time(0))).total_seconds()).quantize(dec("0.001"))
971
+ # logger.debug("function: seconds_of_day")
972
+ # logger.debug(f"{timestamp = }")
973
+
974
+ t = timestamp.time()
975
+ return dec(t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1000000).quantize(dec("0.001"))
761
976
 
762
977
 
763
978
  def sorted_keys(d: dict) -> list:
@@ -787,7 +1002,7 @@ def intfloatstr(s: str) -> int:
787
1002
  return s
788
1003
 
789
1004
 
790
- def distance(p1, p2):
1005
+ def distance(p1: tuple, p2: tuple) -> float:
791
1006
  """
792
1007
  euclidean distance between 2 points
793
1008
  """
@@ -819,6 +1034,62 @@ def angle(vertex: tuple, side1: tuple, side2: tuple) -> float:
819
1034
  )
820
1035
 
821
1036
 
1037
+ def oriented_angle(P1: tuple, P2: tuple, P3: tuple) -> float:
1038
+ """
1039
+ Calculate the oriented angle between two segments.
1040
+
1041
+ Args:
1042
+ P1 (tuple): Coordinates of the vertex
1043
+ P2 (tuple): Coordinates of the first point
1044
+ P3 (tuple): Coordinates of the second point
1045
+
1046
+ Returns:
1047
+ float: The oriented angle between the two segments in degrees.
1048
+ """
1049
+
1050
+ x1, y1 = P1
1051
+ x2, y2 = P2
1052
+ x3, y3 = P1
1053
+ x4, y4 = P3
1054
+
1055
+ angle_AB = math.atan2(y2 - y1, x2 - x1)
1056
+ angle_CD = math.atan2(y4 - y3, x4 - x3)
1057
+
1058
+ oriented_angle = math.degrees(angle_AB - angle_CD)
1059
+
1060
+ return oriented_angle
1061
+
1062
+
1063
+ def oriented_angle_trigo(B: Tuple[float, float], A: Tuple[float, float], C: Tuple[float, float]) -> float:
1064
+ """
1065
+ Calculates the oriented angle between vectors BA and BC, in degrees.
1066
+ The angle is positive in the counter-clockwise (trigonometric) direction.
1067
+
1068
+ Parameters:
1069
+ B: The pivot point (the origin of the vectors BA and BC).
1070
+ A, C: Points that define the vectors.
1071
+
1072
+ Returns:
1073
+ Angle in degrees, between 0 and 360.
1074
+ """
1075
+ # Vectors BA and BC
1076
+ v1 = (A[0] - B[0], A[1] - B[1])
1077
+ v2 = (C[0] - B[0], C[1] - B[1])
1078
+
1079
+ # Dot product and 2D cross product (determinant)
1080
+ dot = v1[0] * v2[0] + v1[1] * v2[1]
1081
+ det = v1[0] * v2[1] - v1[1] * v2[0]
1082
+
1083
+ # Signed angle in radians, then converted to degrees
1084
+ angle_rad = math.atan2(det, dot)
1085
+ angle_deg = math.degrees(angle_rad)
1086
+
1087
+ if angle_deg < 0:
1088
+ angle_deg += 360
1089
+
1090
+ return angle_deg
1091
+
1092
+
822
1093
  def mem_info():
823
1094
  """
824
1095
  get info about total mem, used mem and available mem using:
@@ -836,9 +1107,7 @@ def mem_info():
836
1107
  process = subprocess.run(["free", "-m"], stdout=subprocess.PIPE)
837
1108
  # out, err = process.communicate()
838
1109
  out = process.stdout
839
- _, tot_mem, used_mem, _, _, _, available_mem = [
840
- x.decode("utf-8") for x in out.split(b"\n")[1].split(b" ") if x != b""
841
- ]
1110
+ _, tot_mem, used_mem, _, _, _, available_mem = [x.decode("utf-8") for x in out.split(b"\n")[1].split(b" ") if x != b""]
842
1111
  return False, {
843
1112
  "total_memory": int(tot_mem),
844
1113
  "used_memory": int(used_mem),
@@ -858,9 +1127,7 @@ def mem_info():
858
1127
 
859
1128
  if sys.platform.startswith("win"):
860
1129
  try:
861
- output = subprocess.run(
862
- ["wmic", "computersystem", "get", "TotalPhysicalMemory", "/", "Value"], stdout=subprocess.PIPE
863
- )
1130
+ output = subprocess.run(["wmic", "computersystem", "get", "TotalPhysicalMemory", "/", "Value"], stdout=subprocess.PIPE)
864
1131
  tot_mem = int(output.stdout.strip().split(b"=")[-1].decode("utf-8")) / 1024 / 1024
865
1132
 
866
1133
  output = subprocess.run(["wmic", "OS", "get", "FreePhysicalMemory", "/", "Value"], stdout=subprocess.PIPE)
@@ -873,7 +1140,7 @@ def mem_info():
873
1140
  return True, {"msg": "Unknown operating system"}
874
1141
 
875
1142
 
876
- def polygon_area(poly):
1143
+ def polygon_area(poly: list) -> float:
877
1144
  """
878
1145
  area of polygon
879
1146
  from http://www.mathopenref.com/coordpolygonarea.html
@@ -888,7 +1155,20 @@ def polygon_area(poly):
888
1155
  return abs(tot / 2)
889
1156
 
890
1157
 
891
- def url2path(url):
1158
+ def polyline_length(poly: list) -> float:
1159
+ """
1160
+ length of polyline
1161
+ """
1162
+ tot = 0
1163
+ for p in range(1, len(poly)):
1164
+ x1, y1 = poly[p - 1]
1165
+ x2, y2 = poly[p]
1166
+ tot += ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5
1167
+
1168
+ return tot
1169
+
1170
+
1171
+ def url2path(url: str) -> str:
892
1172
  """
893
1173
  convert URL in local path name
894
1174
  under windows, check if path name begin with /
@@ -919,17 +1199,23 @@ def time2seconds(time_: str) -> dec:
919
1199
  Decimal: time in seconds
920
1200
  """
921
1201
 
922
- try:
923
- flag_neg = "-" in time_
924
- time_ = time_.replace("-", "")
925
- tsplit = time_.split(":")
926
- h, m, s = int(tsplit[0]), int(tsplit[1]), dec(tsplit[2])
927
- return dec(-(h * 3600 + m * 60 + s)) if flag_neg else dec(h * 3600 + m * 60 + s)
928
- except Exception:
929
- return dec("0.000")
1202
+ if " " in time_:
1203
+ try:
1204
+ return dec(str(dt.datetime.strptime(time_, "%Y-%m-%d %H:%M:%S.%f").timestamp()))
1205
+ except Exception:
1206
+ return dec("0.000")
1207
+ else:
1208
+ try:
1209
+ flag_neg = "-" in time_
1210
+ time_ = time_.replace("-", "")
1211
+ tsplit = time_.split(":")
1212
+ h, m, s = int(tsplit[0]), int(tsplit[1]), dec(tsplit[2])
1213
+ return dec(-(h * 3600 + m * 60 + s)) if flag_neg else dec(h * 3600 + m * 60 + s)
1214
+ except Exception:
1215
+ return dec("0.000")
930
1216
 
931
1217
 
932
- def seconds2time(sec: dec) -> str:
1218
+ def seconds2time(sec: dec | None) -> str:
933
1219
  """
934
1220
  convert seconds to hh:mm:ss.sss format
935
1221
 
@@ -938,12 +1224,15 @@ def seconds2time(sec: dec) -> str:
938
1224
  Returns:
939
1225
  str: time in format hh:mm:ss
940
1226
  """
1227
+ if sec is None:
1228
+ return cfg.NA
941
1229
 
942
1230
  if math.isnan(sec):
943
1231
  return cfg.NA
944
1232
 
945
- if sec > 1_600_000_000: # epoch time
946
- t = datetime.datetime.fromtimestamp(float(sec))
1233
+ # if sec > one day treat as date
1234
+ if sec > cfg.DATE_CUTOFF:
1235
+ t = dt.datetime.fromtimestamp(float(sec))
947
1236
  return f"{t:%Y-%m-%d %H:%M:%S}.{t.microsecond / 1000:03.0f}"
948
1237
 
949
1238
  neg_sign = "-" * (sec < 0)
@@ -1002,7 +1291,7 @@ def eol2space(s: str) -> str:
1002
1291
  return s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
1003
1292
 
1004
1293
 
1005
- def test_ffmpeg_path(FFmpegPath):
1294
+ def test_ffmpeg_path(FFmpegPath: str) -> Tuple[bool, str]:
1006
1295
  """
1007
1296
  test if ffmpeg has valid path
1008
1297
 
@@ -1014,22 +1303,20 @@ def test_ffmpeg_path(FFmpegPath):
1014
1303
  str: message
1015
1304
  """
1016
1305
 
1017
- out, error = subprocess.Popen(
1018
- f'"{FFmpegPath}" -version', stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
1019
- ).communicate()
1020
- logging.debug(f"test ffmpeg path output: {out}")
1021
- logging.debug(f"test ffmpeg path error: {error}")
1306
+ out, error = subprocess.Popen(f'"{FFmpegPath}" -version', stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True).communicate()
1307
+ logger.debug(f"test ffmpeg path output: {out}")
1308
+ logger.debug(f"test ffmpeg path error: {error}")
1022
1309
 
1023
1310
  if (b"avconv" in out) or (b"the Libav developers" in error):
1024
1311
  return False, "Please use FFmpeg from https://www.ffmpeg.org in place of FFmpeg from Libav project."
1025
1312
 
1026
1313
  if (b"ffmpeg version" not in out) and (b"ffmpeg version" not in error):
1027
- return False, "FFmpeg is required but it was not found...<br>See https://www.ffmpeg.org"
1314
+ return False, "FFmpeg is required but it was not found.<br>See https://www.ffmpeg.org"
1028
1315
 
1029
1316
  return True, ""
1030
1317
 
1031
1318
 
1032
- def check_ffmpeg_path():
1319
+ def check_ffmpeg_path() -> Tuple[bool, str]:
1033
1320
  """
1034
1321
  check for ffmpeg path
1035
1322
  firstly search for embedded version
@@ -1040,43 +1327,24 @@ def check_ffmpeg_path():
1040
1327
  str: if bool True returns ffmpegpath else returns error message
1041
1328
  """
1042
1329
 
1330
+ # search embedded ffmpeg
1043
1331
  if sys.platform.startswith("linux") or sys.platform.startswith("darwin"):
1044
- ffmpeg_path = pl.Path("")
1045
- # search embedded ffmpeg
1046
- if sys.argv[0].endswith("start_boris.py"):
1047
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "boris" / "misc" / "ffmpeg"
1048
- if sys.argv[0].endswith("__main__.py"):
1049
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "misc" / "ffmpeg"
1050
-
1051
- if not ffmpeg_path.is_file():
1052
- # search global ffmpeg
1053
- ffmpeg_path = "ffmpeg"
1054
-
1055
- # test ffmpeg
1056
- r, msg = test_ffmpeg_path(str(ffmpeg_path))
1057
- if r:
1058
- return True, str(ffmpeg_path)
1059
- else:
1060
- return False, "FFmpeg is not available"
1332
+ ffmpeg_executable = Path("ffmpeg")
1333
+ elif sys.platform.startswith("win"):
1334
+ ffmpeg_executable = Path("ffmpeg.exe")
1061
1335
 
1062
- if sys.platform.startswith("win"):
1063
- ffmpeg_path = pl.Path("")
1064
- # search embedded ffmpeg
1065
- if sys.argv[0].endswith("start_boris.py"):
1066
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "boris" / "misc" / "ffmpeg.exe"
1067
- if sys.argv[0].endswith("__main__.py"):
1068
- ffmpeg_path = pl.Path(sys.argv[0]).resolve().parent / "misc" / "ffmpeg.exe"
1069
-
1070
- if not ffmpeg_path.is_file():
1071
- # search global ffmpeg
1072
- ffmpeg_path = "ffmpeg"
1073
-
1074
- # test ffmpeg
1075
- r, msg = test_ffmpeg_path(str(ffmpeg_path))
1076
- if r:
1077
- return True, str(ffmpeg_path)
1078
- else:
1079
- return False, "FFmpeg is not available"
1336
+ ffmpeg_path = Path(__file__).parent / "misc" / ffmpeg_executable
1337
+
1338
+ if not ffmpeg_path.is_file():
1339
+ # search global ffmpeg
1340
+ ffmpeg_path = ffmpeg_executable
1341
+
1342
+ # test ffmpeg
1343
+ r, msg = test_ffmpeg_path(str(ffmpeg_path))
1344
+ if r:
1345
+ return True, str(ffmpeg_path)
1346
+ else:
1347
+ return False, "FFmpeg is not available"
1080
1348
 
1081
1349
 
1082
1350
  def smart_size_format(n: Union[float, int, str, None]) -> str:
@@ -1098,6 +1366,73 @@ def smart_size_format(n: Union[float, int, str, None]) -> str:
1098
1366
  return f"{n / 1_000_000_000:,.1f} Gb"
1099
1367
 
1100
1368
 
1369
+ def get_systeminfo() -> str:
1370
+ """
1371
+ returns info about the system
1372
+ """
1373
+
1374
+ mpv_lib_version_, mpv_lib_file_path, mpv_api_version = mpv_lib_version()
1375
+
1376
+ system_info = (
1377
+ f"BORIS version: {version.__version__}\n"
1378
+ f"OS: {platform.uname().system} {platform.uname().release} {platform.uname().version}\n"
1379
+ f"CPU: {platform.uname().machine} {platform.uname().processor}\n"
1380
+ f"Python {platform.python_version()} ({'64-bit' if sys.maxsize > 2**32 else '32-bit'})\n"
1381
+ f"Qt {qVersion()} - PySide {pyside6_version}\n"
1382
+ f"MPV library version: {mpv_lib_version_}\n"
1383
+ f"MPV API version: {mpv_api_version}\n"
1384
+ f"MPV library file path: {mpv_lib_file_path}\n\n"
1385
+ )
1386
+
1387
+ r, memory = mem_info()
1388
+ if not r:
1389
+ system_info += (
1390
+ f"Memory (RAM) Total: {memory.get('total_memory', 'Not available'):.2f} Mb "
1391
+ f"Free: {memory.get('free_memory', 'Not available'):.2f} Mb\n\n"
1392
+ )
1393
+
1394
+ return system_info
1395
+
1396
+ """
1397
+ # system info
1398
+ systeminfo = ""
1399
+ if sys.platform.startswith("win"):
1400
+ # systeminfo = subprocess.getoutput("systeminfo")
1401
+ systeminfo = subprocess.run("systeminfo /FO csv /NH", capture_output=True, text=True, encoding="mbcs", shell=True).stdout
1402
+
1403
+ import csv
1404
+ from io import StringIO
1405
+
1406
+ # Parse it as CSV
1407
+ f = StringIO(systeminfo)
1408
+ reader = csv.reader(f)
1409
+ parsed_data = list(reader)[0]
1410
+ # Print specific fields by index
1411
+ info_to_show = ""
1412
+ info_to_show += f"Computer Name: {parsed_data[0]}\n"
1413
+ info_to_show += f"OS Name: {parsed_data[1]}\n"
1414
+ info_to_show += f"OS Version: {parsed_data[2]}\n"
1415
+ info_to_show += f"System Manufacturer: {parsed_data[11]}\n"
1416
+ info_to_show += f"System Model: {parsed_data[12]}\n"
1417
+ info_to_show += f"Processor: {parsed_data[14]}\n"
1418
+ info_to_show += f"Locale: {parsed_data[19]}\n"
1419
+ info_to_show += f"Installed Memory: {parsed_data[22]}\n"
1420
+
1421
+ # info about graphic card
1422
+ graphic_info = subprocess.run(
1423
+ "wmic path win32_videocontroller get name", capture_output=True, text=True, encoding="mbcs", shell=True
1424
+ ).stdout
1425
+ info_to_show += graphic_info.replace("\n", "").replace("Name", "Graphic card model")
1426
+
1427
+ systeminfo = info_to_show
1428
+
1429
+ if sys.platform.startswith("linux"):
1430
+ systeminfo = subprocess.getoutput("cat /etc/*rel*; uname -a")
1431
+
1432
+ return systeminfo
1433
+ """
1434
+
1435
+
1101
1436
  def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1102
1437
  """
1103
1438
  analyse video parameters with ffprobe (if available)
@@ -1109,28 +1444,32 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1109
1444
  Returns:
1110
1445
  dict
1111
1446
  """
1112
- # ffprobe -v quiet -print_format json -show_format -show_streams /tmp/ramdisk/video1.mp4
1447
+
1448
+ # check ffprobe executable in same place than ffmpeg
1113
1449
  ffprobe_bin = ffmpeg_bin.replace("ffmpeg", "ffprobe")
1450
+ if not Path(ffprobe_bin).is_file():
1451
+ if which(ffprobe_bin) is None:
1452
+ return {"error": "ffprobe not found"}
1114
1453
 
1115
1454
  command = f'"{ffprobe_bin}" -hide_banner -v error -print_format json -show_format -show_streams "{file_name}"'
1116
1455
 
1117
- # print(command)
1118
-
1119
1456
  p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
1120
1457
  out, error = p.communicate()
1121
1458
  if error:
1122
- return {"error": f"{error}"}
1459
+ if b"invalid data" in error:
1460
+ return {"error": f"{error}"}
1123
1461
 
1124
1462
  try:
1125
1463
  hasVideo = False
1126
1464
  hasAudio = False
1127
- '''bitrate = None'''
1465
+ """bitrate = None"""
1128
1466
  video_bitrate = None
1129
1467
  audio_bitrate = []
1130
1468
  resolution = None
1131
- fps = 0
1469
+ fps: float = 0
1132
1470
  sample_rate = None
1133
- duration = 0
1471
+ duration = None
1472
+ audio_duration = cfg.NA
1134
1473
  frames_number = None
1135
1474
  size = None
1136
1475
  audio_codec = None
@@ -1164,9 +1503,16 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1164
1503
  fps = eval(stream["r_frame_rate"])
1165
1504
  except Exception:
1166
1505
  fps = 0
1506
+ if fps >= 1000 and "avg_frame_rate" in stream: # case for some h265 video ("r_frame_rate": "1200000/1")
1507
+ try:
1508
+ fps = eval(stream["avg_frame_rate"])
1509
+ except Exception:
1510
+ pass
1167
1511
 
1168
- duration = float(stream["duration"])
1169
- # frames_number = int(stream["nb_frames"]) if "nb_frames" in stream else None
1512
+ fps = round(fps, 3)
1513
+
1514
+ if "duration" in stream:
1515
+ duration = float(stream["duration"])
1170
1516
  if "duration_ts" in stream:
1171
1517
  frames_number = int(stream["duration_ts"])
1172
1518
  elif "nb_frames" in stream:
@@ -1178,11 +1524,19 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1178
1524
 
1179
1525
  if stream["codec_type"] == "audio":
1180
1526
  hasAudio = True
1181
- sample_rate = float(stream["sample_rate"])
1182
- duration = float(stream["duration"])
1527
+ sample_rate = float(stream["sample_rate"]) if "sample_rate" in stream else cfg.NA
1528
+ # TODO manage audio_duration parameter
1529
+ audio_duration = float(stream["duration"]) if "duration" in stream else cfg.NA
1183
1530
  audio_codec = stream["codec_long_name"]
1184
1531
  audio_bitrate.append(int(stream.get("bit_rate", 0)))
1185
1532
 
1533
+ # check duration
1534
+ if duration is None:
1535
+ if "duration" in video_param["format"]:
1536
+ duration = float(video_param["format"]["duration"])
1537
+ else:
1538
+ duration = 0
1539
+
1186
1540
  # check bit rate
1187
1541
  if "bit_rate" in video_param["format"]:
1188
1542
  all_bitrate = int(video_param["format"]["bit_rate"])
@@ -1192,11 +1546,22 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1192
1546
  if video_bitrate is None and all_bitrate is not None:
1193
1547
  video_bitrate = all_bitrate - sum(audio_bitrate)
1194
1548
 
1549
+ # extract format long name
1550
+ format_long_name = video_param["format"]["format_long_name"] if "format_long_name" in video_param["format"] else cfg.NA
1551
+
1552
+ # extract creation time ("creation_time": "2023-03-22T16:50:32.000000Z")
1553
+ creation_time = cfg.NA
1554
+ if "tags" in video_param["format"] and "creation_time" in video_param["format"]["tags"]:
1555
+ creation_time = video_param["format"]["tags"]["creation_time"].replace("T", " ")
1556
+ if "." in creation_time:
1557
+ creation_time = creation_time.split(".")[0]
1558
+
1195
1559
  return {
1196
1560
  "analysis_program": "ffprobe",
1197
1561
  "frames_number": frames_number,
1198
1562
  "duration_ms": duration * 1000,
1199
1563
  "duration": duration,
1564
+ "audio_duration": audio_duration,
1200
1565
  "fps": fps,
1201
1566
  "has_video": hasVideo,
1202
1567
  "has_audio": hasAudio,
@@ -1206,6 +1571,8 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1206
1571
  "file size": size,
1207
1572
  "audio_codec": audio_codec,
1208
1573
  "video_codec": video_codec,
1574
+ "creation_time": creation_time,
1575
+ "format_long_name": format_long_name,
1209
1576
  }
1210
1577
 
1211
1578
  except Exception as e:
@@ -1214,7 +1581,7 @@ def ffprobe_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1214
1581
 
1215
1582
  def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1216
1583
  """
1217
- analyse frame rate and video duration with ffmpeg
1584
+ analyse frame rate and video duration with ffprobe or ffmpeg if ffprobe not available
1218
1585
  Returns parameters: duration, duration_ms, bitrate, frames_number, fps, has_video (True/False), has_audio (True/False)
1219
1586
 
1220
1587
  Args:
@@ -1228,13 +1595,14 @@ def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1228
1595
 
1229
1596
  ffprobe_results = ffprobe_media_analysis(ffmpeg_bin, file_name)
1230
1597
 
1598
+ logger.debug(f"file_name: {file_name}")
1599
+ logger.debug(f"ffprobe_results: {ffprobe_results}")
1600
+
1231
1601
  if ("error" not in ffprobe_results) and (ffprobe_results["bitrate"] is not None):
1232
1602
  return ffprobe_results
1233
1603
  else:
1234
1604
  # use ffmpeg
1235
- command = (
1236
- f'"{ffmpeg_bin}" -hide_banner -i "{file_name}" > {"NUL" if sys.platform.startswith("win") else "/dev/null"}'
1237
- )
1605
+ command = f'"{ffmpeg_bin}" -hide_banner -i "{file_name}" > {"NUL" if sys.platform.startswith("win") else "/dev/null"}'
1238
1606
 
1239
1607
  p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
1240
1608
 
@@ -1292,7 +1660,7 @@ def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1292
1660
  if b"Stream #" in row and b"Video:" in row:
1293
1661
  hasVideo = True
1294
1662
  # get resolution \d{3,5}x\d{3,5}
1295
- re_results = re.search(b"\d{3,5}x\d{3,5}", row, re.IGNORECASE)
1663
+ re_results = re.search(r"\d{3,5}x\d{3,5}", row, re.IGNORECASE)
1296
1664
  if re_results:
1297
1665
  resolution = re_results.group(0).decode("utf-8")
1298
1666
  break
@@ -1317,11 +1685,13 @@ def accurate_media_analysis(ffmpeg_bin: str, file_name: str) -> dict:
1317
1685
  "frames_number": int(fps * duration),
1318
1686
  "duration_ms": duration * 1000,
1319
1687
  "duration": duration,
1688
+ "audio_duration": cfg.NA,
1320
1689
  "fps": fps,
1321
1690
  "has_video": hasVideo,
1322
1691
  "has_audio": hasAudio,
1323
1692
  "bitrate": bitrate,
1324
1693
  "resolution": resolution,
1694
+ "format_long_name": "",
1325
1695
  }
1326
1696
 
1327
1697
 
@@ -1362,12 +1732,37 @@ def all_behaviors(ethogram: dict) -> list:
1362
1732
  return [ethogram[x][cfg.BEHAVIOR_CODE] for x in sorted_keys(ethogram)]
1363
1733
 
1364
1734
 
1735
+ def all_subjects(subjects: dict) -> list:
1736
+ """
1737
+ extract all subjects from the subject configuration dictionary
1738
+
1739
+ Args:
1740
+ subject configuration (dict)
1741
+
1742
+ Returns:
1743
+ list: subjects name
1744
+ """
1745
+
1746
+ return [subjects[x][cfg.SUBJECT_NAME] for x in sorted_keys(subjects)]
1747
+
1748
+
1749
+ def has_coding_map(ethogram: dict, behavior_idx: str) -> bool:
1750
+ """
1751
+ check if behavior index has a coding map
1752
+ """
1753
+ if not ethogram.get(behavior_idx, False):
1754
+ return False
1755
+ if not ethogram[behavior_idx].get("coding map", False):
1756
+ return False
1757
+ return True
1758
+
1759
+
1365
1760
  def dir_images_number(dir_path_str: str) -> dict:
1366
1761
  """
1367
1762
  return number of images in dir_path (see cfg.IMAGE_EXTENSIONS)
1368
1763
  """
1369
1764
 
1370
- dir_path = pl.Path(dir_path_str)
1765
+ dir_path = Path(dir_path_str)
1371
1766
  if not dir_path.is_dir():
1372
1767
  return {"error": f"The directory {dir_path_str} does not exists"}
1373
1768
  img_count = 0
@@ -1405,29 +1800,11 @@ def intersection(A, B, C, D):
1405
1800
  ym = slope * xm + intersept
1406
1801
  else:
1407
1802
  xm = (
1408
- (
1409
- xd * xa * yc
1410
- - xd * xb * yc
1411
- - xd * xa * yb
1412
- - xc * xa * yd
1413
- + xc * xa * yb
1414
- + xd * ya * xb
1415
- + xc * xb * yd
1416
- - xc * ya * xb
1417
- )
1803
+ (xd * xa * yc - xd * xb * yc - xd * xa * yb - xc * xa * yd + xc * xa * yb + xd * ya * xb + xc * xb * yd - xc * ya * xb)
1418
1804
  / (-yb * xd + yb * xc + ya * xd - ya * xc + xb * yd - xb * yc - xa * yd + xa * yc)
1419
1805
  ).quantize(dec(".001"), rounding=ROUND_DOWN)
1420
1806
  ym = (
1421
- (
1422
- yb * xc * yd
1423
- - yb * yc * xd
1424
- - ya * xc * yd
1425
- + ya * yc * xd
1426
- - xa * yb * yd
1427
- + xa * yb * yc
1428
- + ya * xb * yd
1429
- - ya * xb * yc
1430
- )
1807
+ (yb * xc * yd - yb * yc * xd - ya * xc * yd + ya * yc * xd - xa * yb * yd + xa * yb * yc + ya * xb * yd - ya * xb * yc)
1431
1808
  / (-yb * xd + yb * xc + ya * xd - ya * xc + xb * yd - xb * yc - xa * yd + xa * yc)
1432
1809
  ).quantize(dec(".001"), rounding=ROUND_DOWN)
1433
1810
 
@@ -1436,16 +1813,7 @@ def intersection(A, B, C, D):
1436
1813
  ymin1, ymax1 = min(ya, yb), max(ya, yb)
1437
1814
  ymin2, ymax2 = min(yc, yd), max(yc, yd)
1438
1815
 
1439
- return (
1440
- xm >= xmin1
1441
- and xm <= xmax1
1442
- and xm >= xmin2
1443
- and xm <= xmax2
1444
- and ym >= ymin1
1445
- and ym <= ymax1
1446
- and ym >= ymin2
1447
- and ym <= ymax2
1448
- )
1816
+ return xm >= xmin1 and xm <= xmax1 and xm >= xmin2 and xm <= xmax2 and ym >= ymin1 and ym <= ymax1 and ym >= ymin2 and ym <= ymax2
1449
1817
 
1450
1818
  except Exception: # for cases xa=xb=xc=xd
1451
1819
  return True