boris-behav-obs 9.7.7__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 (109) hide show
  1. boris/__init__.py +26 -0
  2. boris/__main__.py +25 -0
  3. boris/about.py +143 -0
  4. boris/add_modifier.py +635 -0
  5. boris/add_modifier_ui.py +303 -0
  6. boris/advanced_event_filtering.py +455 -0
  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 +1110 -0
  18. boris/behavior_binary_table.py +305 -0
  19. boris/behaviors_coding_map.py +239 -0
  20. boris/boris_cli.py +340 -0
  21. boris/cmd_arguments.py +49 -0
  22. boris/coding_pad.py +280 -0
  23. boris/config.py +785 -0
  24. boris/config_file.py +356 -0
  25. boris/connections.py +409 -0
  26. boris/converters.py +333 -0
  27. boris/converters_ui.py +225 -0
  28. boris/cooccurence.py +250 -0
  29. boris/core.py +5901 -0
  30. boris/core_qrc.py +15958 -0
  31. boris/core_ui.py +1107 -0
  32. boris/db_functions.py +324 -0
  33. boris/dev.py +134 -0
  34. boris/dialog.py +1108 -0
  35. boris/duration_widget.py +238 -0
  36. boris/edit_event.py +245 -0
  37. boris/edit_event_ui.py +233 -0
  38. boris/event_operations.py +1040 -0
  39. boris/events_cursor.py +61 -0
  40. boris/events_snapshots.py +596 -0
  41. boris/exclusion_matrix.py +141 -0
  42. boris/export_events.py +1006 -0
  43. boris/export_observation.py +1203 -0
  44. boris/external_processes.py +332 -0
  45. boris/geometric_measurement.py +941 -0
  46. boris/gui_utilities.py +135 -0
  47. boris/image_overlay.py +72 -0
  48. boris/import_observations.py +242 -0
  49. boris/ipc_mpv.py +325 -0
  50. boris/irr.py +634 -0
  51. boris/latency.py +244 -0
  52. boris/measurement_widget.py +161 -0
  53. boris/media_file.py +115 -0
  54. boris/menu_options.py +213 -0
  55. boris/modifier_coding_map_creator.py +1013 -0
  56. boris/modifiers_coding_map.py +157 -0
  57. boris/mpv.py +2016 -0
  58. boris/mpv2.py +2193 -0
  59. boris/observation.py +1453 -0
  60. boris/observation_operations.py +2538 -0
  61. boris/observation_ui.py +679 -0
  62. boris/observations_list.py +337 -0
  63. boris/otx_parser.py +442 -0
  64. boris/param_panel.py +201 -0
  65. boris/param_panel_ui.py +305 -0
  66. boris/player_dock_widget.py +198 -0
  67. boris/plot_data_module.py +536 -0
  68. boris/plot_events.py +634 -0
  69. boris/plot_events_rt.py +237 -0
  70. boris/plot_spectrogram_rt.py +316 -0
  71. boris/plot_waveform_rt.py +230 -0
  72. boris/plugins.py +431 -0
  73. boris/portion/__init__.py +31 -0
  74. boris/portion/const.py +95 -0
  75. boris/portion/dict.py +365 -0
  76. boris/portion/func.py +52 -0
  77. boris/portion/interval.py +581 -0
  78. boris/portion/io.py +181 -0
  79. boris/preferences.py +510 -0
  80. boris/preferences_ui.py +770 -0
  81. boris/project.py +2007 -0
  82. boris/project_functions.py +2041 -0
  83. boris/project_import_export.py +1096 -0
  84. boris/project_ui.py +794 -0
  85. boris/qrc_boris.py +10389 -0
  86. boris/qrc_boris5.py +2579 -0
  87. boris/select_modifiers.py +312 -0
  88. boris/select_observations.py +210 -0
  89. boris/select_subj_behav.py +286 -0
  90. boris/state_events.py +197 -0
  91. boris/subjects_pad.py +106 -0
  92. boris/synthetic_time_budget.py +290 -0
  93. boris/time_budget_functions.py +1136 -0
  94. boris/time_budget_widget.py +1039 -0
  95. boris/transitions.py +365 -0
  96. boris/utilities.py +1810 -0
  97. boris/version.py +24 -0
  98. boris/video_equalizer.py +159 -0
  99. boris/video_equalizer_ui.py +248 -0
  100. boris/video_operations.py +310 -0
  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.7.dist-info/METADATA +139 -0
  105. boris_behav_obs-9.7.7.dist-info/RECORD +109 -0
  106. boris_behav_obs-9.7.7.dist-info/WHEEL +5 -0
  107. boris_behav_obs-9.7.7.dist-info/entry_points.txt +2 -0
  108. boris_behav_obs-9.7.7.dist-info/licenses/LICENSE.TXT +674 -0
  109. boris_behav_obs-9.7.7.dist-info/top_level.txt +1 -0
boris/observation.py ADDED
@@ -0,0 +1,1453 @@
1
+ """
2
+ BORIS
3
+ Behavioral Observation Research Interactive Software
4
+ Copyright 2012-2025 Olivier Friard
5
+
6
+ This file is part of BORIS.
7
+
8
+ BORIS is free software; you can redistribute it and/or modify
9
+ it under the terms of the GNU General Public License as published by
10
+ the Free Software Foundation; either version 3 of the License, or
11
+ any later version.
12
+
13
+ BORIS is distributed in the hope that it will be useful,
14
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ GNU General Public License for more details.
17
+
18
+ You should have received a copy of the GNU General Public License
19
+ along with this program; if not see <http://www.gnu.org/licenses/>.
20
+
21
+ """
22
+
23
+ import logging
24
+ import os
25
+ import pandas as pd
26
+ import pathlib as pl
27
+
28
+ from PySide6.QtCore import Qt
29
+ from PySide6.QtGui import QColor
30
+ from PySide6.QtWidgets import (
31
+ QDialog,
32
+ QVBoxLayout,
33
+ QHBoxLayout,
34
+ QLabel,
35
+ QComboBox,
36
+ QPushButton,
37
+ QMessageBox,
38
+ QSpacerItem,
39
+ QSizePolicy,
40
+ QFileDialog,
41
+ QTableWidgetItem,
42
+ QApplication,
43
+ QMenu,
44
+ QListWidgetItem,
45
+ QHeaderView,
46
+ )
47
+
48
+ from . import config as cfg
49
+ from . import dialog, plot_data_module, project_functions
50
+ from . import utilities as util
51
+ from . import gui_utilities
52
+ from .observation_ui import Ui_Form
53
+
54
+
55
+ class AssignConverter(QDialog):
56
+ """
57
+ dialog for assigning converter to selected column
58
+ """
59
+
60
+ def __init__(self, columns, converters, col_conv):
61
+ super().__init__()
62
+
63
+ self.setWindowTitle("Converters")
64
+
65
+ self.vbox = QVBoxLayout()
66
+
67
+ self.label = QLabel()
68
+ self.label.setText("Assign converter to column")
69
+ self.vbox.addWidget(self.label)
70
+
71
+ self.cbb = []
72
+ for column_idx in columns.split(","):
73
+ hbox = QHBoxLayout()
74
+ hbox.addWidget(QLabel(f"Column #{column_idx}:"))
75
+ self.cbb.append(QComboBox())
76
+ self.cbb[-1].addItems(["None"] + sorted(converters.keys()))
77
+
78
+ if column_idx in col_conv:
79
+ if col_conv[column_idx] in (["None"] + sorted(converters.keys())):
80
+ self.cbb[-1].setCurrentIndex((["None"] + sorted(converters.keys())).index(col_conv[column_idx]))
81
+ else:
82
+ self.cbb[-1].setCurrentIndex(0)
83
+ else:
84
+ self.cbb[-1].setCurrentIndex(0)
85
+ hbox.addWidget(self.cbb[-1])
86
+ self.vbox.addLayout(hbox)
87
+
88
+ hbox1 = QHBoxLayout()
89
+ self.pbOK = QPushButton("OK")
90
+ self.pbOK.clicked.connect(self.accept)
91
+ self.pbCancel = QPushButton("Cancel")
92
+ self.pbCancel.clicked.connect(self.reject)
93
+ spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
94
+ hbox1.addItem(spacerItem)
95
+ hbox1.addWidget(self.pbCancel)
96
+ hbox1.addWidget(self.pbOK)
97
+ self.vbox.addLayout(hbox1)
98
+
99
+ self.setLayout(self.vbox)
100
+
101
+
102
+ class Observation(QDialog, Ui_Form):
103
+ def __init__(self, tmp_dir: str, project_path: str = "", converters: dict = {}, time_format: str = cfg.S, parent=None):
104
+ """
105
+ Args:
106
+ tmp_dir (str): path of temporary directory
107
+ project_path (str): path of project
108
+ converters (dict): converters dictionary
109
+ """
110
+
111
+ super().__init__()
112
+
113
+ self.tmp_dir = tmp_dir
114
+ self.project_path = project_path
115
+ self.converters = converters
116
+ self.time_format = time_format
117
+ self.observation_time_interval: tuple = [0, 0]
118
+ self.mem_dir = ""
119
+ self.test = None
120
+
121
+ self.setupUi(self)
122
+
123
+ # insert duration widget for time offset
124
+ # self.obs_time_offset = duration_widget.Duration_widget(0)
125
+ self.obs_time_offset = dialog.get_time_widget(0)
126
+ self.horizontalLayout_6.insertWidget(1, self.obs_time_offset)
127
+ self.obs_time_offset.setEnabled(False)
128
+
129
+ # time offset
130
+ self.cb_time_offset.stateChanged.connect(self.cb_time_offset_changed)
131
+ # date offset
132
+ """self.cb_date_offset.stateChanged.connect(self.cb_date_offset_changed)"""
133
+
134
+ # observation type
135
+ self.rb_media_files.toggled.connect(self.obs_type_changed)
136
+ self.rb_live.toggled.connect(self.obs_type_changed)
137
+ self.rb_images.toggled.connect(self.obs_type_changed)
138
+
139
+ # button menu for media
140
+
141
+ add_media_menu_items = [
142
+ "media abs path|with absolute path",
143
+ "media rel path|with relative path",
144
+ {
145
+ "from directory": [
146
+ "dir abs path|with absolute path ",
147
+ "dir rel path|wih relative path ",
148
+ ]
149
+ },
150
+ ]
151
+
152
+ self.media_menu = QMenu()
153
+ # Add actions to the menu
154
+ """
155
+ self.action1 = QAction("with absolute path")
156
+ self.action2 = QAction("with relative path")
157
+ self.action3 = QAction("directory with absolute path")
158
+ self.action4 = QAction("directory with relative path")
159
+
160
+ self.menu.addAction(self.action1)
161
+ self.menu.addAction(self.action2)
162
+ self.menu.addAction(self.action3)
163
+ self.menu.addAction(self.action4)
164
+
165
+ # Connect actions to functions
166
+ self.action1.triggered.connect(lambda: self.add_media(mode="media abs path|with absolute path"))
167
+ self.action2.triggered.connect(lambda: self.add_media(mode="media rel path|with relative path"))
168
+ self.action3.triggered.connect(lambda: self.add_media(mode="dir abs path|with absolute path"))
169
+ self.action4.triggered.connect(lambda: self.add_media(mode="dir rel path|wih relative path"))
170
+ """
171
+
172
+ self.media_menu.triggered.connect(lambda x: self.add_media(mode=x.statusTip()))
173
+ self.add_button_menu(add_media_menu_items, self.media_menu)
174
+ self.pbAddVideo.setMenu(self.media_menu)
175
+
176
+ self.pbRemoveVideo.clicked.connect(self.remove_media)
177
+
178
+ # button menu for data file
179
+ data_menu_items = [
180
+ "data abs path|with absolute path",
181
+ "data rel path|with relative path",
182
+ ]
183
+
184
+ self.menu_data = QMenu()
185
+
186
+ # Add actions to the menu
187
+ """
188
+ self.data_action1 = QAction("with absolute path")
189
+ self.data_action2 = QAction("with relative path")
190
+ self.menu_data.addAction(self.data_action1)
191
+ self.menu_data.addAction(self.data_action2)
192
+
193
+ # Connect actions to functions
194
+ self.data_action1.triggered.connect(lambda: self.add_data_file(mode="data abs path|with absolute path"))
195
+ self.data_action2.triggered.connect(lambda: self.add_data_file(mode="data rel path|with relative path"))
196
+ """
197
+
198
+ self.menu_data.triggered.connect(lambda x: self.add_data_file(mode=x.statusTip()))
199
+ self.add_button_menu(data_menu_items, self.menu_data)
200
+ self.pb_add_data_file.setMenu(self.menu_data)
201
+
202
+ # button menu for images
203
+ images_menu_items = [
204
+ "images abs path|with absolute path",
205
+ "images rel path|with relative path",
206
+ ]
207
+
208
+ self.menu_images = QMenu()
209
+
210
+ self.menu_images.triggered.connect(lambda x: self.add_images_directory(mode=x.statusTip()))
211
+ self.add_button_menu(images_menu_items, self.menu_images)
212
+ self.pb_add_directory.setMenu(self.menu_images)
213
+
214
+ self.pb_remove_data_file.clicked.connect(self.remove_data_file)
215
+ self.pb_view_data_head.clicked.connect(self.view_data_file_head_tail)
216
+ self.pb_plot_data.clicked.connect(self.plot_data_file)
217
+
218
+ self.pb_use_media_file_name_as_obsid.clicked.connect(self.use_media_file_name_as_obsid)
219
+ self.pb_use_img_dir_as_obsid.clicked.connect(self.use_img_dir_as_obsid)
220
+
221
+ self.cbVisualizeSpectrogram.clicked.connect(self.extract_wav)
222
+ self.cb_visualize_waveform.clicked.connect(self.extract_wav)
223
+
224
+ self.cb_observation_time_interval.clicked.connect(self.limit_time_interval)
225
+
226
+ self.pbSave.clicked.connect(self.pbSave_clicked)
227
+ self.pbLaunch.clicked.connect(self.pbLaunch_clicked)
228
+ self.pbCancel.clicked.connect(self.pbCancel_clicked)
229
+
230
+ self.tw_data_files.cellDoubleClicked[int, int].connect(self.tw_data_files_cellDoubleClicked)
231
+ self.tw_data_files.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
232
+
233
+ self.twVideo1.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
234
+
235
+ self.mediaDurations, self.mediaFPS, self.mediaHasVideo, self.mediaHasAudio, self.media_creation_time = {}, {}, {}, {}, {}
236
+
237
+ for w in (
238
+ self.cbVisualizeSpectrogram,
239
+ self.cb_visualize_waveform,
240
+ self.cb_observation_time_interval,
241
+ self.cb_media_creation_date_as_offset,
242
+ self.cbCloseCurrentBehaviorsBetweenVideo,
243
+ ):
244
+ w.setEnabled(False)
245
+
246
+ self.cb_observation_time_interval.setEnabled(True)
247
+
248
+ self.cb_start_from_current_time.stateChanged.connect(self.cb_start_from_current_time_changed)
249
+
250
+ # images
251
+ # self.pb_add_directory.clicked.connect(self.add_images_directory)
252
+ self.pb_remove_directory.clicked.connect(self.remove_images_directory)
253
+
254
+ self.tabWidget.setCurrentIndex(0)
255
+
256
+ # geometry
257
+ gui_utilities.restore_geometry(self, "new observation", (800, 650))
258
+
259
+ # def cb_date_offset_changed(self):
260
+ # """
261
+ # activate/desactivate time value
262
+ # """
263
+ # self.de_date_offset.setEnabled(self.cb_date_offset.isChecked())
264
+
265
+ def check_media_creation_date(self):
266
+ """
267
+ check if all media files contain creation date time
268
+ search in metadata then in filename
269
+ """
270
+
271
+ creation_date_not_found: list = []
272
+ flag_filename_used = False
273
+
274
+ self.media_creation_time = {}
275
+
276
+ if self.cb_media_creation_date_as_offset.isChecked():
277
+ for row in range(self.twVideo1.rowCount()):
278
+ if self.twVideo1.item(row, 2).text(): # media file path
279
+ date_time_original = util.extract_video_creation_date(
280
+ project_functions.full_path(self.twVideo1.item(row, 2).text(), self.project_path)
281
+ )
282
+ if date_time_original is None:
283
+ date_time_file_name = util.extract_date_time_from_file_name(self.twVideo1.item(row, 2).text())
284
+ if date_time_file_name is None:
285
+ creation_date_not_found.append(self.twVideo1.item(row, 2).text())
286
+ else:
287
+ self.media_creation_time[self.twVideo1.item(row, 2).text()] = date_time_file_name
288
+ flag_filename_used = True
289
+ else:
290
+ self.media_creation_time[self.twVideo1.item(row, 2).text()] = date_time_original
291
+
292
+ if creation_date_not_found:
293
+ QMessageBox.warning(
294
+ self, cfg.programName, "The creation date time was not found for all media file(s).\nThe option was disabled."
295
+ )
296
+ self.cb_media_creation_date_as_offset.setChecked(False)
297
+ self.media_creation_time = {}
298
+ return 1
299
+
300
+ elif flag_filename_used:
301
+ QMessageBox.information(
302
+ self, cfg.programName, "The creation date time was not found in metadata. The media file name(s) was/were used"
303
+ )
304
+
305
+ return 0
306
+
307
+ def cb_time_offset_changed(self):
308
+ """
309
+ activate/desactivate date value
310
+ """
311
+ self.obs_time_offset.setEnabled(self.cb_time_offset.isChecked())
312
+
313
+ def use_media_file_name_as_obsid(self) -> None:
314
+ """
315
+ set observation id with the media file name value (without path)
316
+ """
317
+ if not self.twVideo1.rowCount():
318
+ QMessageBox.critical(self, cfg.programName, "A media file must be loaded in player #1")
319
+ return
320
+
321
+ first_media_file: str = ""
322
+ for row in range(self.twVideo1.rowCount()):
323
+ if int(self.twVideo1.cellWidget(row, 0).currentText()) == 1:
324
+ first_media_file = self.twVideo1.item(row, 2).text()
325
+ break
326
+ # check if player #1 is used
327
+ if not first_media_file:
328
+ QMessageBox.critical(self, cfg.programName, "A media file must be loaded in player #1")
329
+ return
330
+
331
+ self.leObservationId.setText(pl.Path(first_media_file).name)
332
+
333
+ def use_img_dir_as_obsid(self) -> None:
334
+ """
335
+ set observation id with the images directory (without path)
336
+ """
337
+
338
+ if not self.lw_images_directory.count():
339
+ QMessageBox.critical(self, cfg.programName, "You have to select at least one images directory")
340
+ return
341
+
342
+ self.leObservationId.setText(pl.Path(self.lw_images_directory.item(0).text()).name)
343
+
344
+ def obs_type_changed(self) -> None:
345
+ """
346
+ change stacked widget page in base at the observation type
347
+ """
348
+
349
+ for idx, rb in enumerate((self.rb_media_files, self.rb_live, self.rb_images)):
350
+ if rb.isChecked():
351
+ self.sw_observation_type.setCurrentIndex(idx + 1)
352
+
353
+ # hide 'limit observation to time interval' for images
354
+ self.cb_observation_time_interval.setEnabled(not self.rb_images.isChecked())
355
+
356
+ def add_images_directory(self, mode: str):
357
+ """
358
+ add path to images directory
359
+ """
360
+
361
+ if mode.split("|")[0] not in (
362
+ "images abs path",
363
+ "images rel path",
364
+ ):
365
+ QMessageBox.critical(
366
+ self,
367
+ cfg.programName,
368
+ (f"Wrong mode to add a pictures directory {mode}"),
369
+ )
370
+ return
371
+
372
+ # check if project saved
373
+ if (" w/o" in mode or " rel " in mode) and (not self.project_file_name):
374
+ QMessageBox.critical(
375
+ self,
376
+ cfg.programName,
377
+ ("It is not possible to add a pictures directory with a relative path if the project is not already saved"),
378
+ )
379
+ return
380
+
381
+ fd = QFileDialog()
382
+ fd.setDirectory(os.path.expanduser("~") if (" abs " in mode) else str(pl.Path(self.project_path).parent))
383
+
384
+ dir_path = fd.getExistingDirectory(None, "Select directory")
385
+
386
+ if not dir_path:
387
+ return
388
+
389
+ result = util.dir_images_number(dir_path)
390
+ if not result.get("number of images", 0):
391
+ response = dialog.MessageDialog(
392
+ cfg.programName,
393
+ f"The directory does not contain images ({','.join(cfg.IMAGE_EXTENSIONS)})",
394
+ ["Cancel", "Add directory"],
395
+ )
396
+ if response == "Cancel":
397
+ return
398
+
399
+ # store directory for next usage
400
+ self.mem_dir = str(pl.Path(dir_path))
401
+
402
+ if " rel " in mode:
403
+ try:
404
+ pl.Path(dir_path).parent.relative_to(pl.Path(self.project_path).parent)
405
+ except ValueError:
406
+ QMessageBox.critical(
407
+ self,
408
+ cfg.programName,
409
+ f"The directory <b>{pl.Path(dir_path).parent}</b> is not contained in <b>{pl.Path(self.project_path).parent}</b>.",
410
+ )
411
+ return
412
+
413
+ if " rel " in mode:
414
+ # convert to relative path (relative to BORIS project file)
415
+ self.lw_images_directory.addItem(QListWidgetItem(str(pl.Path(dir_path).relative_to(pl.Path(self.project_path).parent))))
416
+ else:
417
+ self.lw_images_directory.addItem(QListWidgetItem(dir_path))
418
+ self.lb_images_info.setText(f"Number of images in {dir_path}: {result.get('number of images', 0)}")
419
+
420
+ def remove_images_directory(self):
421
+ """
422
+ remove dir path from the list
423
+ """
424
+ self.lw_images_directory.takeItem(self.lw_images_directory.currentRow())
425
+
426
+ def add_button_menu(self, data, menu_obj):
427
+ """
428
+ add menu option from dictionary
429
+ """
430
+ if isinstance(data, dict):
431
+ for k, v in data.items():
432
+ sub_menu = QMenu(k, menu_obj)
433
+ menu_obj.addMenu(sub_menu)
434
+ self.add_button_menu(v, sub_menu)
435
+ elif isinstance(data, list):
436
+ for element in data:
437
+ self.add_button_menu(element, menu_obj)
438
+ else:
439
+ action = menu_obj.addAction(data.split("|")[1])
440
+ # tips are used to discriminate the menu option
441
+ action.setStatusTip(data.split("|")[0])
442
+ action.setIconVisibleInMenu(False)
443
+
444
+ def cb_start_from_current_time_changed(self):
445
+ """
446
+ enable/disable radiobox for type of time selection
447
+ """
448
+ self.rb_day_time.setEnabled(self.cb_start_from_current_time.isChecked())
449
+ self.rb_epoch_time.setEnabled(self.cb_start_from_current_time.isChecked())
450
+
451
+ def limit_time_interval(self):
452
+ """
453
+ ask user a time interval for limiting the media observation
454
+ """
455
+
456
+ if self.cb_observation_time_interval.isChecked():
457
+ time_interval_dialog = dialog.Ask_time(0)
458
+ if self.time_format == cfg.S:
459
+ time_interval_dialog.time_widget.rb_seconds.setChecked(True)
460
+ if self.time_format == cfg.HHMMSS:
461
+ time_interval_dialog.time_widget.rb_time.setChecked(True)
462
+ time_interval_dialog.time_widget.set_time(0)
463
+ time_interval_dialog.setWindowTitle("Start observation at")
464
+ time_interval_dialog.label.setText("<b>Start</b> observation at")
465
+ start_time, stop_time = 0, 0
466
+ if time_interval_dialog.exec_():
467
+ start_time = time_interval_dialog.time_widget.get_time()
468
+ else:
469
+ self.cb_observation_time_interval.setChecked(False)
470
+ return
471
+ time_interval_dialog.time_widget.set_time(0)
472
+ time_interval_dialog.setWindowTitle("Stop observation at")
473
+ time_interval_dialog.label.setText("<b>Stop</b> observation at")
474
+ if time_interval_dialog.exec_():
475
+ stop_time = time_interval_dialog.time_widget.get_time()
476
+ else:
477
+ self.cb_observation_time_interval.setChecked(False)
478
+ return
479
+
480
+ if start_time or stop_time:
481
+ if stop_time <= start_time:
482
+ QMessageBox.critical(self, cfg.programName, "The stop time comes before the start time")
483
+ self.cb_observation_time_interval.setChecked(False)
484
+ return
485
+ self.observation_time_interval = [start_time, stop_time]
486
+ self.cb_observation_time_interval.setText(
487
+ (
488
+ "Limit observation to a time interval: "
489
+ f"{util.smart_time_format(start_time, self.time_format)} - {util.smart_time_format(stop_time, self.time_format)}"
490
+ )
491
+ )
492
+ else:
493
+ self.observation_time_interval = [0, 0]
494
+ self.cb_observation_time_interval.setText("Limit observation to a time interval")
495
+
496
+ def tw_data_files_cellDoubleClicked(self, row, column):
497
+ """
498
+ double click on "Converters column"
499
+ """
500
+ if column == cfg.PLOT_DATA_CONVERTERS_IDX:
501
+ if self.tw_data_files.item(row, cfg.PLOT_DATA_COLUMNS_IDX).text():
502
+ w = AssignConverter(
503
+ self.tw_data_files.item(row, cfg.PLOT_DATA_COLUMNS_IDX).text(),
504
+ self.converters,
505
+ eval(self.tw_data_files.item(row, cfg.PLOT_DATA_CONVERTERS_IDX).text())
506
+ if self.tw_data_files.item(row, cfg.PLOT_DATA_CONVERTERS_IDX).text()
507
+ else "",
508
+ )
509
+
510
+ if w.exec_():
511
+ d = {}
512
+ for col_idx, cb in zip(self.tw_data_files.item(row, cfg.PLOT_DATA_COLUMNS_IDX).text().split(","), w.cbb):
513
+ if cb.currentText() != "None":
514
+ d[col_idx] = cb.currentText()
515
+ self.tw_data_files.item(row, cfg.PLOT_DATA_CONVERTERS_IDX).setText(str(d))
516
+ else:
517
+ QMessageBox.critical(self, cfg.programName, "Select the columns to plot (time,value)")
518
+
519
+ def plot_data_file(self):
520
+ """
521
+ show plot
522
+ check if data can be plotted
523
+ """
524
+
525
+ if self.pb_plot_data.text() != "Show plot":
526
+ self.test.close_plot()
527
+ self.text = None
528
+ # update button text
529
+ self.pb_plot_data.setText("Show plot")
530
+ return
531
+
532
+ if self.tw_data_files.selectedIndexes() or self.tw_data_files.rowCount() == 1:
533
+ if self.tw_data_files.rowCount() == 1:
534
+ row_idx = 0
535
+ else:
536
+ row_idx = self.tw_data_files.selectedIndexes()[0].row()
537
+
538
+ filename = self.tw_data_files.item(row_idx, cfg.PLOT_DATA_FILEPATH_IDX).text()
539
+ columns_to_plot = self.tw_data_files.item(row_idx, cfg.PLOT_DATA_COLUMNS_IDX).text()
540
+ plot_title = self.tw_data_files.item(row_idx, cfg.PLOT_DATA_PLOTTITLE_IDX).text()
541
+
542
+ # load converters in dictionary
543
+ if self.tw_data_files.item(row_idx, cfg.PLOT_DATA_CONVERTERS_IDX).text():
544
+ column_converter = eval(self.tw_data_files.item(row_idx, cfg.PLOT_DATA_CONVERTERS_IDX).text())
545
+ else:
546
+ column_converter = {}
547
+
548
+ variable_name = self.tw_data_files.item(row_idx, cfg.PLOT_DATA_VARIABLENAME_IDX).text()
549
+ time_interval = int(self.tw_data_files.item(row_idx, cfg.PLOT_DATA_TIMEINTERVAL_IDX).text())
550
+ time_offset = int(self.tw_data_files.item(row_idx, cfg.PLOT_DATA_TIMEOFFSET_IDX).text())
551
+
552
+ substract_first_value = self.tw_data_files.cellWidget(row_idx, cfg.PLOT_DATA_SUBSTRACT1STVALUE_IDX).currentText()
553
+
554
+ plot_color = self.tw_data_files.cellWidget(row_idx, cfg.PLOT_DATA_PLOTCOLOR_IDX).currentText()
555
+
556
+ data_file_path = project_functions.full_path(filename, self.project_path)
557
+
558
+ if not data_file_path:
559
+ QMessageBox.critical(
560
+ self,
561
+ cfg.programName,
562
+ (
563
+ f"Data file not found:\n{filename}\n"
564
+ "If the file path is not stored the data file "
565
+ "must be in the same directory than your project"
566
+ ),
567
+ )
568
+ return
569
+
570
+ self.test = plot_data_module.Plot_data(
571
+ data_file_path,
572
+ time_interval, # time interval
573
+ time_offset, # time offset
574
+ plot_color, # plot style
575
+ plot_title, # plot title
576
+ variable_name,
577
+ columns_to_plot,
578
+ substract_first_value,
579
+ self.converters,
580
+ column_converter,
581
+ log_level=logging.getLogger().getEffectiveLevel(),
582
+ )
583
+
584
+ if self.test.error_msg:
585
+ QMessageBox.critical(self, cfg.programName, f"Impossible to plot data:\n{self.test.error_msg}")
586
+ self.test = None
587
+ return
588
+
589
+ # self.test.setWindowFlags(self.test.windowFlags() | Qt.WindowStaysOnTopHint)
590
+ self.test.show()
591
+ self.test.update_plot(0)
592
+ # update button text
593
+ self.pb_plot_data.setText("Close plot")
594
+ else:
595
+ QMessageBox.warning(self, cfg.programName, "Select a data file")
596
+
597
+ def not_editable_column_color(self):
598
+ """
599
+ return a color for the not editable column
600
+ """
601
+ window_color = QApplication.instance().palette().window().color()
602
+ return QColor(window_color.red() - 5, window_color.green() - 5, window_color.blue() - 5)
603
+
604
+ def add_data_file(self, mode: str):
605
+ """
606
+ user select a data file to be plotted synchronously with media file
607
+
608
+ Args:
609
+ mode (str): statusTip() data abs path / data rel path
610
+ """
611
+
612
+ if mode.split("|")[0] not in (
613
+ "data abs path",
614
+ "data rel path",
615
+ ):
616
+ QMessageBox.critical(
617
+ self,
618
+ cfg.programName,
619
+ (f"Wrong mode to add a data file {mode}"),
620
+ )
621
+ return
622
+
623
+ # check if project saved
624
+ if (" w/o" in mode or " rel " in mode) and (not self.project_file_name):
625
+ QMessageBox.critical(
626
+ self,
627
+ cfg.programName,
628
+ ("It is not possible to add a data file with a relative path if the project is not already saved"),
629
+ )
630
+ return
631
+
632
+ # limit to 2 files
633
+ if self.tw_data_files.rowCount() >= 2:
634
+ QMessageBox.warning(
635
+ self,
636
+ cfg.programName,
637
+ ("It is not yet possible to plot more than 2 external data sourcesThis limitation will be removed in future"),
638
+ )
639
+ return
640
+
641
+ fd = QFileDialog()
642
+ fd.setDirectory(os.path.expanduser("~") if (" abs " in mode) else str(pl.Path(self.project_path).parent))
643
+
644
+ file_name, _ = fd.getOpenFileName(self, "Add data file", "", "All files (*)")
645
+ if not file_name:
646
+ return
647
+
648
+ columns_to_plot = "1,2" # columns to plot by default
649
+
650
+ # check data file
651
+ file_parameters = util.check_txt_file(file_name)
652
+
653
+ if "error" in file_parameters:
654
+ QMessageBox.critical(self, cfg.programName, f"Error on file {file_name}: {file_parameters['error']}")
655
+ return
656
+
657
+ if not file_parameters["homogeneous"]: # the number of columns is not constant
658
+ QMessageBox.critical(self, cfg.programName, "This file does not contain a constant number of columns")
659
+ return
660
+
661
+ header, footer = util.return_file_header_footer(file_name, file_row_number=file_parameters["rows number"], row_number=5)
662
+
663
+ if not header:
664
+ QMessageBox.critical(self, cfg.programName, f"Error on file {pl.Path(file_name).name}")
665
+ return
666
+
667
+ w = dialog.View_data()
668
+ w.setWindowTitle("View data")
669
+ w.lb.setText(f"View first and last rows of <b>{pl.Path(file_name).name}</b> file")
670
+
671
+ w.tw.setColumnCount(file_parameters["fields number"])
672
+ if footer:
673
+ hf = header + [file_parameters["separator"].join(["..."] * file_parameters["fields number"])] + footer
674
+ w.tw.setRowCount(len(header) + len(footer) + 1)
675
+ else:
676
+ hf = header
677
+ w.tw.setRowCount(len(header))
678
+
679
+ for idx, row in enumerate(hf):
680
+ for col, v in enumerate(row.split(file_parameters["separator"])):
681
+ item = QTableWidgetItem(v)
682
+ item.setFlags(Qt.ItemIsEnabled)
683
+ w.tw.setItem(idx, col, item)
684
+
685
+ # stats
686
+ try:
687
+ df = pd.read_csv(file_name, sep=file_parameters["separator"], header=None if not file_parameters["has header"] else [0])
688
+ # set columns names to based 1 index
689
+ if not file_parameters["has header"]:
690
+ df.columns = range(1, len(df.columns) + 1)
691
+
692
+ stats_out = str(df.describe())
693
+ except Exception:
694
+ stats_out = "Not available"
695
+ w.stats.setPlainText(stats_out)
696
+
697
+ while True:
698
+ flag_ok = True
699
+ if w.exec_():
700
+ columns_to_plot = w.le.text().replace(" ", "")
701
+ for col in columns_to_plot.split(","):
702
+ try:
703
+ col_idx = int(col)
704
+ except ValueError:
705
+ QMessageBox.critical(self, cfg.programName, f"<b>{col}</b> does not seem to be a column index")
706
+ flag_ok = False
707
+ break
708
+ if col_idx <= 0 or col_idx > file_parameters["fields number"]:
709
+ QMessageBox.critical(self, cfg.programName, f"<b>{col}</b> is not a valid column index")
710
+ flag_ok = False
711
+ break
712
+ if flag_ok:
713
+ break
714
+ else:
715
+ return
716
+
717
+ else:
718
+ return
719
+
720
+ self.tw_data_files.setRowCount(self.tw_data_files.rowCount() + 1)
721
+
722
+ if " rel " in mode:
723
+ try:
724
+ file_path = str(pl.Path(file_name).relative_to(pl.Path(self.project_path).parent))
725
+ except ValueError:
726
+ QMessageBox.critical(
727
+ self,
728
+ cfg.programName,
729
+ f"The directory <b>{pl.Path(file_name).parent}</b> is not contained in <b>{pl.Path(self.project_path).parent}</b>.",
730
+ )
731
+ return
732
+
733
+ else: # save absolute path
734
+ file_path = file_name
735
+
736
+ for col_idx, value in zip(
737
+ [
738
+ cfg.PLOT_DATA_FILEPATH_IDX,
739
+ cfg.PLOT_DATA_COLUMNS_IDX,
740
+ cfg.PLOT_DATA_PLOTTITLE_IDX,
741
+ cfg.PLOT_DATA_VARIABLENAME_IDX,
742
+ cfg.PLOT_DATA_CONVERTERS_IDX,
743
+ cfg.PLOT_DATA_TIMEINTERVAL_IDX,
744
+ cfg.PLOT_DATA_TIMEOFFSET_IDX,
745
+ ],
746
+ [file_path, columns_to_plot, "", "", "", "60", "0"],
747
+ ):
748
+ item = QTableWidgetItem(value)
749
+ if col_idx == cfg.PLOT_DATA_CONVERTERS_IDX:
750
+ item.setFlags(Qt.ItemIsEnabled)
751
+ # item.setBackground(QColor(230, 230, 230))
752
+ item.setBackground(self.not_editable_column_color())
753
+ self.tw_data_files.setItem(self.tw_data_files.rowCount() - 1, col_idx, item)
754
+
755
+ # substract first value
756
+ combobox = QComboBox()
757
+ combobox.addItems(["True", "False"])
758
+ self.tw_data_files.setCellWidget(self.tw_data_files.rowCount() - 1, cfg.PLOT_DATA_SUBSTRACT1STVALUE_IDX, combobox)
759
+
760
+ # plot line color
761
+ combobox = QComboBox()
762
+ combobox.addItems(cfg.DATA_PLOT_STYLES)
763
+ self.tw_data_files.setCellWidget(self.tw_data_files.rowCount() - 1, cfg.PLOT_DATA_PLOTCOLOR_IDX, combobox)
764
+
765
+ def view_data_file_head_tail(self) -> None:
766
+ """
767
+ view first and last rows of data file
768
+ """
769
+
770
+ if not self.tw_data_files.selectedIndexes() and self.tw_data_files.rowCount() != 1:
771
+ QMessageBox.warning(self, cfg.programName, "Select a data file")
772
+
773
+ if self.tw_data_files.rowCount() == 1:
774
+ data_file_path = project_functions.full_path(self.tw_data_files.item(0, 0).text(), self.project_path)
775
+ columns_to_plot = self.tw_data_files.item(0, 1).text()
776
+ else: # selected file
777
+ data_file_path = project_functions.full_path(
778
+ self.tw_data_files.item(self.tw_data_files.selectedIndexes()[0].row(), 0).text(), self.project_path
779
+ )
780
+ columns_to_plot = self.tw_data_files.item(self.tw_data_files.selectedIndexes()[0].row(), 1).text()
781
+
782
+ file_parameters = util.check_txt_file(data_file_path)
783
+
784
+ if "error" in file_parameters:
785
+ QMessageBox.critical(self, cfg.programName, f"Error on file {data_file_path}: {file_parameters['error']}")
786
+ return
787
+ header, footer = util.return_file_header_footer(data_file_path, file_row_number=file_parameters["rows number"], row_number=5)
788
+
789
+ if not header:
790
+ QMessageBox.critical(self, cfg.programName, f"Error on file {pl.Path(data_file_path).name}")
791
+ return
792
+
793
+ w = dialog.View_data()
794
+ w.setWindowTitle("View data")
795
+ w.lb.setText(f"View first and last rows of <b>{pl.Path(data_file_path).name}</b> file")
796
+ w.pbOK.setText(cfg.CLOSE)
797
+ w.label.setText("Index of columns to plot")
798
+ w.le.setEnabled(False)
799
+ w.le.setText(columns_to_plot)
800
+ w.pbCancel.setVisible(False)
801
+
802
+ w.tw.setColumnCount(file_parameters["fields number"])
803
+ if footer:
804
+ hf = header + [file_parameters["separator"].join(["..."] * file_parameters["fields number"])] + footer
805
+ w.tw.setRowCount(len(header) + len(footer) + 1)
806
+ else:
807
+ hf = header
808
+ w.tw.setRowCount(len(header))
809
+
810
+ for idx, row in enumerate(hf):
811
+ for col, v in enumerate(row.split(file_parameters["separator"])):
812
+ item = QTableWidgetItem(v)
813
+ item.setFlags(Qt.ItemIsEnabled)
814
+ w.tw.setItem(idx, col, item)
815
+
816
+ # stats
817
+ try:
818
+ df = pd.read_csv(
819
+ data_file_path,
820
+ sep=file_parameters["separator"],
821
+ header=None if not file_parameters["has header"] else [0],
822
+ )
823
+ # set columns names to based 1 index
824
+ if not file_parameters["has header"]:
825
+ df.columns = range(1, len(df.columns) + 1)
826
+
827
+ stats_out = str(df.describe())
828
+ except Exception:
829
+ stats_out = "Not available"
830
+ w.stats.setPlainText(stats_out)
831
+
832
+ w.exec_()
833
+
834
+ def extract_wav(self):
835
+ """
836
+ extract wav of all media files loaded in player #1
837
+ """
838
+
839
+ if not self.cbVisualizeSpectrogram.isChecked() and not self.cb_visualize_waveform.isChecked():
840
+ return
841
+
842
+ flag_wav_produced = False
843
+ # check if player 1 is selected
844
+ flag_player1 = False
845
+ for row in range(self.twVideo1.rowCount()):
846
+ if self.twVideo1.cellWidget(row, 0).currentText() == "1":
847
+ flag_player1 = True
848
+
849
+ if not flag_player1:
850
+ QMessageBox.critical(self, cfg.programName, "The player #1 is not selected")
851
+ self.cbVisualizeSpectrogram.setChecked(False)
852
+ self.cb_visualize_waveform.setChecked(False)
853
+ return
854
+
855
+ if True:
856
+ w = dialog.Info_widget()
857
+ w.resize(350, 100)
858
+ # w.setWindowFlags(Qt.WindowStaysOnTopHint)
859
+ w.setWindowTitle("BORIS")
860
+ w.label.setText("Extracting WAV from media files...")
861
+
862
+ for row in range(self.twVideo1.rowCount()):
863
+ # check if player 1
864
+ if self.twVideo1.cellWidget(row, 0).currentText() != "1":
865
+ continue
866
+
867
+ media_file_path = project_functions.full_path(self.twVideo1.item(row, cfg.MEDIA_FILE_PATH_IDX).text(), self.project_path)
868
+ if self.twVideo1.item(row, cfg.HAS_AUDIO_IDX).text() == "False":
869
+ QMessageBox.critical(self, cfg.programName, f"The media file {media_file_path} does not seem to have audio")
870
+ flag_wav_produced = False
871
+ break
872
+
873
+ if os.path.isfile(media_file_path):
874
+ w.show()
875
+ QApplication.processEvents()
876
+
877
+ if util.extract_wav(self.ffmpeg_bin, media_file_path, self.tmp_dir) == "":
878
+ QMessageBox.critical(
879
+ self,
880
+ cfg.programName,
881
+ f"Error during extracting WAV of the media file {media_file_path}",
882
+ )
883
+ flag_wav_produced = False
884
+ break
885
+
886
+ w.hide()
887
+
888
+ flag_wav_produced = True
889
+ else:
890
+ QMessageBox.warning(self, cfg.programName, f"<b>{media_file_path}</b> file not found")
891
+
892
+ if not flag_wav_produced:
893
+ self.cbVisualizeSpectrogram.setChecked(False)
894
+ self.cb_visualize_waveform.setChecked(False)
895
+
896
+ def check_creation_date(self) -> int:
897
+ """
898
+ check if media file exists
899
+ check if Creation Date tag is present in metadata of media file
900
+
901
+ Returns:
902
+ int: 0 if OK else error code: 1 -> media file date not used, 2 -> media file not found
903
+
904
+ """
905
+
906
+ # check if media files exist
907
+
908
+ media_not_found_list: list = []
909
+ for row in range(self.twVideo1.rowCount()):
910
+ if not pl.Path(self.twVideo1.item(row, 2).text()).is_file():
911
+ media_not_found_list.append(self.twVideo1.item(row, 2).text())
912
+
913
+ """
914
+ if media_list:
915
+ dlg = dialog.Results_dialog()
916
+ dlg.setWindowTitle("BORIS")
917
+ dlg.pbOK.setText("OK")
918
+ dlg.pbCancel.setVisible(False)
919
+ dlg.ptText.clear()
920
+ dlg.ptText.appendHtml(
921
+ (
922
+ "Some media file(s) were not found:<br>"
923
+ f"{'<br>'.join(media_list)}<br><br>"
924
+ "You cannot select the <b>Use the media creation date/time option</b>."
925
+ )
926
+ )
927
+ dlg.ptText.moveCursor(QTextCursor.Start)
928
+ ret = dlg.exec_()
929
+ """
930
+
931
+ """
932
+ not_tagged_media_list: list = []
933
+ for row in range(self.twVideo1.rowCount()):
934
+ if self.twVideo1.item(row, 2).text() not in media_not_found_list:
935
+ media_info = util.accurate_media_analysis(self.ffmpeg_bin, self.twVideo1.item(row, 2).text())
936
+ if cfg.MEDIA_CREATION_TIME not in media_info or media_info[cfg.MEDIA_CREATION_TIME] == cfg.NA:
937
+ not_tagged_media_list.append(self.twVideo1.item(row, 2).text())
938
+ else:
939
+ creation_time_epoch = int(dt.datetime.strptime(media_info[cfg.MEDIA_CREATION_TIME], "%Y-%m-%d %H:%M:%S").timestamp())
940
+ self.media_creation_time[self.twVideo1.item(row, 2).text()] = creation_time_epoch
941
+
942
+ if not_tagged_media_list:
943
+ dlg = dialog.Results_dialog()
944
+ dlg.setWindowTitle("BORIS")
945
+ dlg.pbOK.setText("Yes")
946
+ dlg.pbCancel.setVisible(True)
947
+ dlg.pbCancel.setText("No")
948
+
949
+ dlg.ptText.clear()
950
+ dlg.ptText.appendHtml(
951
+ (
952
+ "Some media file does not contain the <b>Creation date/time</b> metadata tag:<br>"
953
+ f"{'<br>'.join(not_tagged_media_list)}<br><br>"
954
+ "Use the media file date/time instead?"
955
+ )
956
+ )
957
+ dlg.ptText.moveCursor(QTextCursor.Start)
958
+ ret = dlg.exec_()
959
+
960
+ if ret == 1: # use file creation time
961
+ for media in not_tagged_media_list:
962
+ self.media_creation_time[media] = pl.Path(media).stat().st_ctime
963
+ return 0 # OK use media file creation date/time
964
+ else:
965
+ self.cb_media_creation_date_as_offset.setChecked(False)
966
+ self.media_creation_time = {}
967
+ return 1
968
+ else:
969
+ return 0 # OK all media have a 'creation time' tag
970
+ """
971
+ return 0
972
+
973
+ def closeEvent(self, event):
974
+ """
975
+ close observation windows
976
+ """
977
+ if self.test is not None:
978
+ self.test.close_plot()
979
+ self.text = None
980
+
981
+ def pbCancel_clicked(self):
982
+ """
983
+ observation creation cancelled
984
+ """
985
+ if self.test is not None:
986
+ self.test.close_plot()
987
+ self.text = None
988
+ self.reject()
989
+
990
+ def check_parameters(self) -> bool:
991
+ """
992
+ check observation parameters
993
+
994
+ Returns:
995
+ bool: True if everything is OK else False
996
+
997
+ """
998
+
999
+ def is_numeric(s):
1000
+ """
1001
+ check if s is numeric (float)
1002
+
1003
+ Args:
1004
+ s (str/int/float): value to test
1005
+
1006
+ Returns:
1007
+ boolean: True if numeric else False
1008
+ """
1009
+ try:
1010
+ float(s)
1011
+ return True
1012
+ except ValueError:
1013
+ return False
1014
+
1015
+ # check if observation id not empty
1016
+ if not self.leObservationId.text():
1017
+ QMessageBox.critical(
1018
+ self,
1019
+ cfg.programName,
1020
+ "The <b>observation id</b> is mandatory and must be unique.",
1021
+ )
1022
+ return False
1023
+
1024
+ # check if observation_type
1025
+ if not any((self.rb_media_files.isChecked(), self.rb_live.isChecked(), self.rb_images.isChecked())):
1026
+ QMessageBox.critical(
1027
+ self,
1028
+ cfg.programName,
1029
+ "Choose an observation type.",
1030
+ )
1031
+ return False
1032
+
1033
+ # check if offset is correct
1034
+ if self.cb_time_offset.isChecked():
1035
+ if self.obs_time_offset.get_time() is None:
1036
+ QMessageBox.critical(
1037
+ self,
1038
+ cfg.programName,
1039
+ "Check the time offset value.",
1040
+ )
1041
+ return False
1042
+
1043
+ if self.rb_media_files.isChecked(): # observation based on media file(s)
1044
+ # check if media file exists
1045
+ media_file_not_found: list = []
1046
+ for row in range(self.twVideo1.rowCount()):
1047
+ # check if media file exists
1048
+ if not pl.Path(self.twVideo1.item(row, 2).text()).is_file():
1049
+ media_file_not_found.append(self.twVideo1.item(row, 2).text())
1050
+
1051
+ # check player number
1052
+ players_list: list = []
1053
+ players: dict = {} # for storing duration
1054
+ for row in range(self.twVideo1.rowCount()):
1055
+ player_idx = int(self.twVideo1.cellWidget(row, 0).currentText())
1056
+ players_list.append(player_idx)
1057
+ if player_idx not in players:
1058
+ players[player_idx] = []
1059
+ players[player_idx].append(util.time2seconds(self.twVideo1.item(row, 3).text()))
1060
+
1061
+ # check if player #1 is used
1062
+ if not players_list or min(players_list) > 1:
1063
+ QMessageBox.critical(
1064
+ self,
1065
+ cfg.programName,
1066
+ "A media file must be loaded in player #1",
1067
+ )
1068
+ return False
1069
+
1070
+ # check if players are used in crescent order
1071
+ if set(list(range(min(players_list), max(players_list) + 1))) != set(players_list):
1072
+ QMessageBox.critical(
1073
+ self,
1074
+ cfg.programName,
1075
+ "Some player are not used. Please reorganize your media files",
1076
+ )
1077
+ return False
1078
+
1079
+ # check if more media in player #1 and media in other players
1080
+ """
1081
+ if len(players[1]) > 1 and set(players.keys()) != {1}:
1082
+ QMessageBox.critical(
1083
+ self,
1084
+ cfg.programName,
1085
+ (
1086
+ "It is not possible to play another media synchronously "
1087
+ "when many media are queued in the first media player"
1088
+ ),
1089
+ )
1090
+ return False
1091
+ """
1092
+
1093
+ # check if more media enqueued on many players
1094
+ if len(set(players.keys())) > 1: # many players used
1095
+ if max([len(players[x]) for x in players]) > 1:
1096
+ QMessageBox.critical(
1097
+ self,
1098
+ cfg.programName,
1099
+ (
1100
+ "It is not possible to enqueue media files "
1101
+ "on more than one player.<br>"
1102
+ "You can use the <b>Merge media files</b> tool (see Tools > Media file > Merge media files"
1103
+ ),
1104
+ )
1105
+ return False
1106
+
1107
+ # check that the longuest media is in player #1
1108
+ durations: list = []
1109
+ for i in sorted(list(players.keys())):
1110
+ durations.append(sum(players[i]))
1111
+ if [x for x in durations[1:] if x > durations[0]]:
1112
+ QMessageBox.critical(self, cfg.programName, "The longuest media file(s) must be loaded in player #1")
1113
+ return False
1114
+
1115
+ # check offset for media files
1116
+ for row in range(self.twVideo1.rowCount()):
1117
+ if not is_numeric(self.twVideo1.item(row, 1).text()):
1118
+ QMessageBox.critical(
1119
+ self,
1120
+ cfg.programName,
1121
+ (
1122
+ "The offset value "
1123
+ f"<b>{self.twVideo1.item(row, 1).text()}</b>"
1124
+ " is not recognized as a numeric value.<br>"
1125
+ "Use decimal number of seconds (e.g. -58.5 or 32)"
1126
+ ),
1127
+ )
1128
+ return False
1129
+
1130
+ # check if offset set and only player #1 is used
1131
+ if len(set(players_list)) == 1:
1132
+ for row in range(self.twVideo1.rowCount()):
1133
+ if float(self.twVideo1.item(row, 1).text()):
1134
+ QMessageBox.critical(
1135
+ self,
1136
+ cfg.programName,
1137
+ (
1138
+ "It is not possible to use offset value(s) with only one player,<br>"
1139
+ "The offset values are use to synchronise various players."
1140
+ ),
1141
+ )
1142
+ return False
1143
+
1144
+ # check offset for external data files
1145
+ for row in range(self.tw_data_files.rowCount()):
1146
+ if not is_numeric(self.tw_data_files.item(row, cfg.PLOT_DATA_TIMEOFFSET_IDX).text()):
1147
+ QMessageBox.critical(
1148
+ self,
1149
+ cfg.programName,
1150
+ (
1151
+ "The external data file start value "
1152
+ f"<b>{self.tw_data_files.item(row, cfg.PLOT_DATA_TIMEOFFSET_IDX).text()}</b>"
1153
+ " is not recognized as a numeric value.<br>"
1154
+ "Use decimal number of seconds (e.g. -58.5 or 32)"
1155
+ ),
1156
+ )
1157
+ return False
1158
+
1159
+ # check media creation time tag in metadata
1160
+ # Disable because the check will be made at the observation start
1161
+ """
1162
+ if self.cb_media_creation_date_as_offset.isChecked():
1163
+ if self.check_creation_date():
1164
+ return False
1165
+ """
1166
+
1167
+ # check media creation date time (if option enabled)
1168
+ if self.check_media_creation_date():
1169
+ return False
1170
+
1171
+ if self.rb_images.isChecked(): # observation based on images directory
1172
+ if not self.lw_images_directory.count():
1173
+ QMessageBox.critical(self, cfg.programName, "You have to select at least one images directory")
1174
+ return False
1175
+
1176
+ # check if indep variables are correct type
1177
+ for row in range(self.twIndepVariables.rowCount()):
1178
+ if self.twIndepVariables.item(row, 1).text() == cfg.NUMERIC:
1179
+ if self.twIndepVariables.item(row, 2).text() and not is_numeric(self.twIndepVariables.item(row, 2).text()):
1180
+ QMessageBox.critical(
1181
+ self,
1182
+ cfg.programName,
1183
+ f"The <b>{self.twIndepVariables.item(row, 0).text()}</b> variable must be numeric!",
1184
+ )
1185
+ return False
1186
+
1187
+ # check if new obs and observation id already present or if edit obs and id changed
1188
+ if (self.mode == "new") or (self.mode == "edit" and self.leObservationId.text() != self.mem_obs_id):
1189
+ if self.leObservationId.text() in self.pj[cfg.OBSERVATIONS]:
1190
+ QMessageBox.critical(
1191
+ self,
1192
+ cfg.programName,
1193
+ (
1194
+ f"The observation id <b>{self.leObservationId.text()}</b> is already used!<br>"
1195
+ f"{self.pj[cfg.OBSERVATIONS][self.leObservationId.text()]['description']}<br>"
1196
+ f"{self.pj[cfg.OBSERVATIONS][self.leObservationId.text()]['date']}"
1197
+ ),
1198
+ )
1199
+ return False
1200
+
1201
+ # check if numeric indep variable values are numeric
1202
+ for row in range(self.twIndepVariables.rowCount()):
1203
+ if self.twIndepVariables.item(row, 1).text() == cfg.NUMERIC:
1204
+ if self.twIndepVariables.item(row, 2).text() and not is_numeric(self.twIndepVariables.item(row, 2).text()):
1205
+ QMessageBox.critical(
1206
+ self,
1207
+ cfg.programName,
1208
+ f"The <b>{self.twIndepVariables.item(row, 0).text()}</b> variable must be numeric!",
1209
+ )
1210
+ return False
1211
+
1212
+ return True
1213
+
1214
+ def pbLaunch_clicked(self):
1215
+ """
1216
+ Close dialog and start the observation
1217
+ """
1218
+
1219
+ if self.check_parameters():
1220
+ if self.test is not None:
1221
+ self.test.close_plot()
1222
+ self.text = None
1223
+ self.done(2)
1224
+
1225
+ def pbSave_clicked(self):
1226
+ """
1227
+ Close window and save observation
1228
+ """
1229
+ if self.check_parameters():
1230
+ self.state = "accepted"
1231
+ if self.test is not None:
1232
+ self.test.close_plot()
1233
+ self.text = None
1234
+ self.accept()
1235
+ else:
1236
+ self.state = "refused"
1237
+
1238
+ def check_media(self, file_path: str, mode: str) -> tuple:
1239
+ """
1240
+ check media and add them to list view if duration > 0
1241
+
1242
+ Args:
1243
+ file_path (str): media file path to be checked
1244
+ mode (str): mode for adding media file
1245
+
1246
+ Returns:
1247
+ bool: False if file is media else True
1248
+ str: error message or empty string
1249
+ """
1250
+
1251
+ logging.debug(f"check_media function for {file_path}")
1252
+
1253
+ media_info = util.accurate_media_analysis(self.ffmpeg_bin, file_path)
1254
+
1255
+ logging.debug(f"{media_info=}")
1256
+
1257
+ if "error" in media_info:
1258
+ return (True, media_info["error"])
1259
+
1260
+ if media_info["format_long_name"] == "Tele-typewriter":
1261
+ return (True, "Text file")
1262
+
1263
+ if media_info["duration"] > 0:
1264
+ if " rel " in mode:
1265
+ # convert to relative path (relative to BORIS project file)
1266
+ file_path = str(pl.Path(file_path).relative_to(pl.Path(self.project_path).parent))
1267
+
1268
+ self.mediaDurations[file_path] = float(media_info["duration"])
1269
+ elif media_info["has_video"] is False and media_info["audio_duration"]:
1270
+ self.mediaDurations[file_path] = float(media_info["audio_duration"])
1271
+ else:
1272
+ return (True, "Media duration not available")
1273
+
1274
+ self.mediaFPS[file_path] = float(media_info["fps"])
1275
+ self.mediaHasVideo[file_path] = media_info["has_video"]
1276
+ self.mediaHasAudio[file_path] = media_info["has_audio"]
1277
+
1278
+ logging.debug(f"{file_path=}")
1279
+
1280
+ self.add_media_to_listview(file_path)
1281
+ return (False, "")
1282
+
1283
+ def update_media_options(self):
1284
+ """
1285
+ update the media options
1286
+ """
1287
+ for w in (
1288
+ self.cbVisualizeSpectrogram,
1289
+ self.cb_visualize_waveform,
1290
+ self.cb_observation_time_interval,
1291
+ self.cb_media_creation_date_as_offset,
1292
+ ):
1293
+ w.setEnabled(self.twVideo1.rowCount() > 0)
1294
+
1295
+ # enable stop ongoing state events if n. media > 1
1296
+ self.cbCloseCurrentBehaviorsBetweenVideo.setEnabled(self.twVideo1.rowCount() > 0)
1297
+
1298
+ # self.creation_date_as_offset()
1299
+
1300
+ def add_media(self, mode: str):
1301
+ """
1302
+ add media
1303
+
1304
+ Args:
1305
+ mode (str): mode for adding the media file
1306
+ """
1307
+
1308
+ if mode.split("|")[0] not in (
1309
+ "media abs path",
1310
+ "media rel path",
1311
+ "dir abs path",
1312
+ "dir rel path",
1313
+ ):
1314
+ QMessageBox.critical(
1315
+ self,
1316
+ cfg.programName,
1317
+ (f"Wrong mode to add media {mode}"),
1318
+ )
1319
+ return
1320
+
1321
+ # check if project saved
1322
+ if (" w/o" in mode or " rel " in mode) and (not self.project_file_name):
1323
+ QMessageBox.critical(
1324
+ self,
1325
+ cfg.programName,
1326
+ ("It is not possible to add a media file without path or with a relative path if the project is not already saved"),
1327
+ )
1328
+ return
1329
+
1330
+ fd = QFileDialog()
1331
+ if self.mem_dir:
1332
+ fd.setDirectory(self.mem_dir if (" abs " in mode) else str(pl.Path(self.project_path).parent))
1333
+ else:
1334
+ fd.setDirectory(os.path.expanduser("~") if (" abs " in mode) else str(pl.Path(self.project_path).parent))
1335
+
1336
+ if "media " in mode:
1337
+ file_paths, _ = fd.getOpenFileNames(self, "Add media file(s)", "", "All files (*)")
1338
+
1339
+ logging.debug(f"{file_paths=}")
1340
+
1341
+ if file_paths:
1342
+ # store directory for next usage
1343
+ self.mem_dir = str(pl.Path(file_paths[0]).parent)
1344
+ # check if media dir in contained in the BORIS file project dir
1345
+ if " rel " in mode:
1346
+ try:
1347
+ pl.Path(file_paths[0]).parent.relative_to(pl.Path(self.project_path).parent)
1348
+ except ValueError:
1349
+ QMessageBox.critical(
1350
+ self,
1351
+ cfg.programName,
1352
+ f"The directory <b>{pl.Path(file_paths[0]).parent}</b> is not contained in <b>{pl.Path(self.project_path).parent}</b>.",
1353
+ )
1354
+ return
1355
+
1356
+ for file_path in file_paths:
1357
+ (error, msg) = self.check_media(file_path, mode)
1358
+ if error:
1359
+ QMessageBox.critical(self, cfg.programName, f"<b>{file_path}</b>. {msg}")
1360
+
1361
+ if "dir " in mode: # add media from dir
1362
+ dir_name = fd.getExistingDirectory(self, "Select directory")
1363
+ if dir_name:
1364
+ response = ""
1365
+ for file_path in sorted(pl.Path(dir_name).glob("*")):
1366
+ if not file_path.is_file():
1367
+ continue
1368
+ (error, msg) = self.check_media(str(file_path), mode)
1369
+ if error:
1370
+ if response != "Skip all non media files":
1371
+ response = dialog.MessageDialog(
1372
+ cfg.programName,
1373
+ f"<b>{file_path}</b> {msg}",
1374
+ ["Continue", "Skip all non media files", cfg.CANCEL],
1375
+ )
1376
+ if response == cfg.CANCEL:
1377
+ break
1378
+ # ask to use directory name / path as observation id
1379
+ if response != cfg.CANCEL:
1380
+ selected_obs_id = dialog.MessageDialog(
1381
+ cfg.programName,
1382
+ "Select the observation id",
1383
+ [dir_name, str(pl.Path(dir_name).name), cfg.CANCEL],
1384
+ )
1385
+ if selected_obs_id != cfg.CANCEL:
1386
+ self.leObservationId.setText(selected_obs_id)
1387
+
1388
+ self.update_media_options()
1389
+
1390
+ def add_media_to_listview(self, file_name):
1391
+ """
1392
+ add media file path to list widget
1393
+ """
1394
+ # add a row
1395
+ self.twVideo1.setRowCount(self.twVideo1.rowCount() + 1)
1396
+
1397
+ for col_idx, s in enumerate(
1398
+ (
1399
+ None,
1400
+ 0,
1401
+ file_name,
1402
+ util.seconds2time(self.mediaDurations[file_name]),
1403
+ f"{self.mediaFPS[file_name]:.2f}",
1404
+ self.mediaHasVideo[file_name],
1405
+ self.mediaHasAudio[file_name],
1406
+ )
1407
+ ):
1408
+ if col_idx == 0: # player combobox
1409
+ combobox = QComboBox()
1410
+ combobox.addItems(cfg.ALL_PLAYERS)
1411
+ self.twVideo1.setCellWidget(self.twVideo1.rowCount() - 1, col_idx, combobox)
1412
+ else:
1413
+ item = QTableWidgetItem(f"{s}")
1414
+ if col_idx != 1: # only offset is editable by user
1415
+ item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
1416
+
1417
+ self.twVideo1.setItem(self.twVideo1.rowCount() - 1, col_idx, item)
1418
+
1419
+ def remove_data_file(self):
1420
+ """
1421
+ remove all selected data file from list widget
1422
+ """
1423
+ if self.tw_data_files.selectedIndexes():
1424
+ rows_to_delete = set([x.row() for x in self.tw_data_files.selectedIndexes()])
1425
+ for row in sorted(rows_to_delete, reverse=True):
1426
+ self.tw_data_files.removeRow(row)
1427
+ else:
1428
+ QMessageBox.warning(self, cfg.programName, "No data file selected")
1429
+
1430
+ def remove_media(self):
1431
+ """
1432
+ remove all selected media files from list widget
1433
+ """
1434
+
1435
+ if not self.twVideo1.selectedIndexes():
1436
+ QMessageBox.warning(self, cfg.programName, "No media file selected")
1437
+ return
1438
+
1439
+ rows_to_delete = set([x.row() for x in self.twVideo1.selectedIndexes()])
1440
+ for row in sorted(rows_to_delete, reverse=True):
1441
+ media_path = self.twVideo1.item(row, cfg.MEDIA_FILE_PATH_IDX).text()
1442
+ self.twVideo1.removeRow(row)
1443
+ if media_path not in [self.twVideo1.item(idx, cfg.MEDIA_FILE_PATH_IDX).text() for idx in range(self.twVideo1.rowCount())]:
1444
+ try:
1445
+ del self.mediaDurations[media_path]
1446
+ except NameError:
1447
+ pass
1448
+ try:
1449
+ del self.mediaFPS[media_path]
1450
+ except NameError:
1451
+ pass
1452
+
1453
+ self.update_media_options()