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/events_cursor.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ BORIS
3
+ Behavioral Observation Research Interactive Software
4
+ Copyright 2012-2025 Olivier Friard
5
+
6
+
7
+ This program is free software; you can redistribute it and/or modify
8
+ it under the terms of the GNU General Public License as published by
9
+ the Free Software Foundation; either version 2 of the License, or
10
+ (at your option) any later version.
11
+
12
+ This program is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU General Public License for more details.
16
+
17
+ You should have received a copy of the GNU General Public License
18
+ along with this program; if not, write to the Free Software
19
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
20
+ MA 02110-1301, USA.
21
+
22
+ """
23
+
24
+ from PySide6.QtCore import QPoint, Qt
25
+ from PySide6.QtGui import QPolygon, QPen, QColor, QBrush, QPainter
26
+ from PySide6.QtWidgets import QStyledItemDelegate
27
+
28
+
29
+ class StyledItemDelegateTriangle(QStyledItemDelegate):
30
+ """
31
+ painter for tv_events with current time highlighting
32
+ """
33
+
34
+ def __init__(self, row, parent=None):
35
+ super(StyledItemDelegateTriangle, self).__init__(parent)
36
+ self.row = row
37
+
38
+ def paint(self, painter, option, index):
39
+ """
40
+ draw a red triangle on ceel corresponfing to current event
41
+ """
42
+
43
+ super(StyledItemDelegateTriangle, self).paint(painter, option, index)
44
+
45
+ if self.row == -1:
46
+ return
47
+ if index.row() == self.row:
48
+ triangle = QPolygon(
49
+ [
50
+ QPoint(option.rect.x() + 15, option.rect.y()),
51
+ QPoint(option.rect.x(), option.rect.y() - 5),
52
+ QPoint(option.rect.x(), option.rect.y() + 5),
53
+ ]
54
+ )
55
+
56
+ painter.save()
57
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
58
+ painter.setBrush(QBrush(QColor(Qt.red)))
59
+ painter.setPen(QPen(QColor(Qt.red)))
60
+ painter.drawPolygon(triangle)
61
+ painter.restore()
@@ -0,0 +1,596 @@
1
+ """
2
+ BORIS
3
+ Behavioral Observation Research Interactive Software
4
+ Copyright 2012-2025 Olivier Friard
5
+
6
+
7
+ This program is free software; you can redistribute it and/or modify
8
+ it under the terms of the GNU General Public License as published by
9
+ the Free Software Foundation; either version 2 of the License, or
10
+ (at your option) any later version.
11
+
12
+ This program is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU General Public License for more details.
16
+
17
+ You should have received a copy of the GNU General Public License
18
+ along with this program; if not, write to the Free Software
19
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
20
+ MA 02110-1301, USA.
21
+
22
+ """
23
+
24
+ import logging
25
+ import os
26
+ import pathlib as pl
27
+ import subprocess
28
+ from decimal import Decimal as dec
29
+
30
+ from PySide6.QtWidgets import QApplication, QFileDialog, QInputDialog, QMessageBox
31
+
32
+ from . import config as cfg
33
+ from . import db_functions, dialog, project_functions, select_observations, select_subj_behav
34
+ from . import utilities as util
35
+
36
+
37
+ def events_snapshots(self):
38
+ """
39
+ create snapshots corresponding to coded events
40
+ if observations are from media file and media files have video
41
+ """
42
+
43
+ _, selected_observations = select_observations.select_observations2(
44
+ self, cfg.MULTIPLE, windows_title="Select observations for snapshots"
45
+ )
46
+ if not selected_observations:
47
+ return
48
+
49
+ # check if obs are MEDIA
50
+ live_images_obs_list = []
51
+ for obs_id in selected_observations:
52
+ if self.pj[cfg.OBSERVATIONS][obs_id][cfg.TYPE] in [cfg.LIVE, cfg.IMAGES]:
53
+ live_images_obs_list.append(obs_id)
54
+
55
+ if live_images_obs_list:
56
+ out = "The following observations are live observations or observation from images and will be removed from analysis<br><br>"
57
+ out += "<br>".join(live_images_obs_list)
58
+ results = dialog.Results_dialog()
59
+ results.setWindowTitle(cfg.programName)
60
+ results.ptText.setReadOnly(True)
61
+ results.ptText.appendHtml(out)
62
+ results.pbSave.setVisible(False)
63
+ results.pbCancel.setVisible(True)
64
+ if not results.exec_():
65
+ return
66
+
67
+ # remove live observations
68
+ selected_observations = [x for x in selected_observations if x not in live_images_obs_list]
69
+ if not selected_observations:
70
+ return
71
+
72
+ # check if state events are paired
73
+ not_ok, selected_observations = project_functions.check_state_events(self.pj, selected_observations)
74
+ if not_ok or not selected_observations:
75
+ return
76
+
77
+ parameters = select_subj_behav.choose_obs_subj_behav_category(
78
+ self,
79
+ selected_observations,
80
+ start_coding=dec("NaN"),
81
+ end_coding=dec("NaN"),
82
+ show_include_modifiers=False,
83
+ show_exclude_non_coded_behaviors=False,
84
+ n_observations=len(selected_observations),
85
+ )
86
+
87
+ if parameters == {}:
88
+ return
89
+
90
+ if not parameters[cfg.SELECTED_SUBJECTS] or not parameters[cfg.SELECTED_BEHAVIORS]:
91
+ return
92
+
93
+ ib = dialog.Input_dialog(
94
+ label_caption="Choose parameters",
95
+ elements_list=[
96
+ ("dsb", "Time interval around the events (in seconds)", 0.0, 86400, 1, 0, 3),
97
+ ("il", "Bitmap format", (("JPG - small size / low quality", ""), ("PNG - big size / high quality", ""))),
98
+ ],
99
+ title="Extract frames",
100
+ )
101
+ if not ib.exec_():
102
+ return
103
+ time_interval = util.float2decimal(ib.elements["Time interval around the events (in seconds)"].value())
104
+ if "JPG" in ib.elements["Bitmap format"].currentText():
105
+ frame_bitmap_format = "jpg"
106
+ elif "PNG" in ib.elements["Bitmap format"].currentText():
107
+ frame_bitmap_format = "png"
108
+ else:
109
+ return
110
+
111
+ # directory for saving frames
112
+ export_dir = QFileDialog.getExistingDirectory(
113
+ self,
114
+ "Choose a directory to extract events",
115
+ os.path.expanduser("~"),
116
+ options=QFileDialog.ShowDirsOnly,
117
+ )
118
+ if not export_dir:
119
+ return
120
+
121
+ cursor = db_functions.load_events_in_db(
122
+ self.pj,
123
+ parameters[cfg.SELECTED_SUBJECTS],
124
+ selected_observations,
125
+ parameters[cfg.SELECTED_BEHAVIORS],
126
+ time_interval=cfg.TIME_FULL_OBS,
127
+ )
128
+
129
+ for obs_id in selected_observations:
130
+ for nplayer in self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE]:
131
+ if not self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer]:
132
+ continue
133
+ duration1 = [] # in seconds
134
+ for mediaFile in self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer]:
135
+ duration1.append(self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.LENGTH][mediaFile])
136
+
137
+ for subject in parameters[cfg.SELECTED_SUBJECTS]:
138
+ for behavior in parameters[cfg.SELECTED_BEHAVIORS]:
139
+ cursor.execute(
140
+ "SELECT occurence FROM events WHERE observation = ? AND subject = ? AND code = ?",
141
+ (obs_id, subject, behavior),
142
+ )
143
+ rows = [{"occurence": util.float2decimal(r["occurence"])} for r in cursor.fetchall()]
144
+
145
+ behavior_state = project_functions.event_type(behavior, self.pj[cfg.ETHOGRAM])
146
+
147
+ for idx, row in enumerate(rows):
148
+ mediaFileIdx = [idx1 for idx1, x in enumerate(duration1) if row["occurence"] >= sum(duration1[0:idx1])][-1]
149
+
150
+ # check if media has video
151
+ flag_no_video = False
152
+ try:
153
+ flag_no_video = not self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.HAS_VIDEO][
154
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]
155
+ ]
156
+ except Exception:
157
+ flag_no_video = True
158
+
159
+ if flag_no_video:
160
+ logging.debug(f"Media {self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]} does not have video")
161
+ flag_no_video = True
162
+ response = dialog.MessageDialog(
163
+ cfg.programName,
164
+ (
165
+ "The following media file does not have video.<br>"
166
+ f"{self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]}"
167
+ ),
168
+ [cfg.OK, "Abort"],
169
+ )
170
+ if response == cfg.OK:
171
+ continue
172
+ if response == "Abort":
173
+ return
174
+
175
+ # check FPS
176
+ mediafile_fps = 0
177
+ try:
178
+ if self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.FPS][
179
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]
180
+ ]:
181
+ mediafile_fps = util.float2decimal(
182
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.FPS][
183
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]
184
+ ]
185
+ )
186
+ except Exception:
187
+ mediafile_fps = 0
188
+
189
+ if not mediafile_fps:
190
+ logging.debug(f"FPS not found for {self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]}")
191
+ response = dialog.MessageDialog(
192
+ cfg.programName,
193
+ (
194
+ "The FPS was not found for the following media file:<br>"
195
+ f"{self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]}"
196
+ ),
197
+ [cfg.OK, "Abort"],
198
+ )
199
+ if response == cfg.OK:
200
+ continue
201
+ if response == "Abort":
202
+ return
203
+
204
+ global_start = dec("0.000") if row["occurence"] < time_interval else round(row["occurence"] - time_interval, 3)
205
+ start = round(
206
+ row["occurence"]
207
+ - time_interval
208
+ - util.float2decimal(sum(duration1[0:mediaFileIdx]))
209
+ - self.pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET],
210
+ 3,
211
+ )
212
+ if start < time_interval:
213
+ start = dec("0.000")
214
+
215
+ if behavior_state in cfg.POINT_EVENT_TYPES:
216
+ media_path = project_functions.full_path(
217
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx],
218
+ self.projectFileName,
219
+ )
220
+
221
+ vframes = 1 if not time_interval else int(mediafile_fps * time_interval * 2)
222
+ if vframes == 0:
223
+ vframes = 1
224
+
225
+ if behavior_state in cfg.STATE_EVENT_TYPES:
226
+ if idx % 2 == 0:
227
+ # check if stop is on same media file
228
+ if (
229
+ mediaFileIdx
230
+ != [idx1 for idx1, x in enumerate(duration1) if rows[idx + 1]["occurence"] >= sum(duration1[0:idx1])][
231
+ -1
232
+ ]
233
+ ):
234
+ response = dialog.MessageDialog(
235
+ cfg.programName,
236
+ (
237
+ "The event extends on 2 video. "
238
+ "At the moment it no possible to extract frames "
239
+ "for this type of event.<br>"
240
+ ),
241
+ [cfg.OK, "Abort"],
242
+ )
243
+ if response == cfg.OK:
244
+ continue
245
+ if response == "Abort":
246
+ return
247
+
248
+ # globalStop = round(rows[idx + 1]["occurence"] + time_interval, 3)
249
+
250
+ stop = round(
251
+ rows[idx + 1]["occurence"]
252
+ + time_interval
253
+ - util.float2decimal(sum(duration1[0:mediaFileIdx]))
254
+ - self.pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET],
255
+ 3,
256
+ )
257
+
258
+ # check if start after length of media
259
+ try:
260
+ if (
261
+ start
262
+ > self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.LENGTH][
263
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]
264
+ ]
265
+ ):
266
+ continue
267
+ except Exception:
268
+ continue
269
+
270
+ media_path = project_functions.full_path(
271
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx],
272
+ self.projectFileName,
273
+ )
274
+
275
+ vframes = int((stop - start) * mediafile_fps + time_interval * mediafile_fps * 2)
276
+ if vframes == 0:
277
+ vframes = 1
278
+
279
+ else:
280
+ continue
281
+
282
+ ffmpeg_command = (
283
+ f'"{self.ffmpeg_bin}" '
284
+ f"-ss {start:.3f} "
285
+ f'-i "{media_path}" '
286
+ f"-vframes {vframes} "
287
+ f'"{export_dir}{os.sep}'
288
+ f"{util.safeFileName(obs_id).replace(' ', '-')}"
289
+ f"_PLAYER{nplayer}"
290
+ f"_{util.safeFileName(subject).replace(' ', '-')}"
291
+ f"_{util.safeFileName(behavior).replace(' ', '-')}"
292
+ f'_{global_start:.3f}_%08d.{frame_bitmap_format}"'
293
+ )
294
+
295
+ logging.debug(f"ffmpeg command: {ffmpeg_command}")
296
+
297
+ p = subprocess.Popen(ffmpeg_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
298
+ out, _ = p.communicate()
299
+
300
+ self.statusbar.showMessage(f"Frames extracted in {export_dir}", 0)
301
+
302
+
303
+ def extract_events(self):
304
+ """
305
+ extract sub-sequences from media files corresponding to coded events with FFmpeg
306
+ in case of point event, from -n to +n seconds are extracted (n is asked to user)
307
+ """
308
+
309
+ _, selected_observations = select_observations.select_observations2(
310
+ self, cfg.MULTIPLE, windows_title="Select observations for extracting events"
311
+ )
312
+ if not selected_observations:
313
+ return
314
+
315
+ # check if obs are MEDIA
316
+ live_images_obs_list = []
317
+ for obs_id in selected_observations:
318
+ if self.pj[cfg.OBSERVATIONS][obs_id][cfg.TYPE] in [cfg.LIVE, cfg.IMAGES]:
319
+ live_images_obs_list.append(obs_id)
320
+
321
+ if live_images_obs_list:
322
+ out = "The following observations are live observations or observation from pictures and will be removed from analysis<br><br>"
323
+ out += "<br>".join(live_images_obs_list)
324
+ results = dialog.Results_dialog()
325
+ results.setWindowTitle(cfg.programName)
326
+ results.ptText.setReadOnly(True)
327
+ results.ptText.appendHtml(out)
328
+ results.pbSave.setVisible(False)
329
+ results.pbCancel.setVisible(True)
330
+ if results.exec_():
331
+ # remove live observations
332
+ selected_observations = [x for x in selected_observations if x not in live_images_obs_list]
333
+ if not selected_observations:
334
+ return
335
+ else:
336
+ return
337
+
338
+ # check if state events are paired
339
+ not_ok, selected_observations = project_functions.check_state_events(self.pj, selected_observations)
340
+ if not_ok or not selected_observations:
341
+ return
342
+
343
+ parameters = select_subj_behav.choose_obs_subj_behav_category(
344
+ self,
345
+ selected_observations,
346
+ start_coding=dec("NaN"),
347
+ end_coding=dec("NaN"),
348
+ show_include_modifiers=False,
349
+ show_exclude_non_coded_behaviors=False,
350
+ )
351
+ if parameters == {}:
352
+ return
353
+
354
+ if not parameters[cfg.SELECTED_SUBJECTS] or not parameters[cfg.SELECTED_BEHAVIORS]:
355
+ QMessageBox.warning(None, cfg.programName, "Select subject(s) and behavior(s) to analyze")
356
+ return
357
+
358
+ # Ask for time interval around the event
359
+ while True:
360
+ text, ok = QInputDialog.getDouble(self, "Time interval around the events", "Time (in seconds):", 0.0, 0.0, 86400, 1)
361
+ if not ok:
362
+ return
363
+ try:
364
+ timeOffset = util.float2decimal(text)
365
+ break
366
+ except Exception:
367
+ QMessageBox.warning(self, cfg.programName, f"<b>{text}</b> is not recognized as time")
368
+
369
+ # ask for video / audio extraction
370
+ items_to_extract, ok = QInputDialog.getItem(
371
+ self, "Tracks to extract", "Tracks", ("Video and audio", "Only video", "Only audio"), 0, False
372
+ )
373
+ if not ok:
374
+ return
375
+
376
+ export_dir = QFileDialog.getExistingDirectory(
377
+ self,
378
+ "Choose a directory to extract events",
379
+ os.path.expanduser("~"),
380
+ options=QFileDialog.ShowDirsOnly,
381
+ )
382
+ if not export_dir:
383
+ return
384
+
385
+ cursor = db_functions.load_events_in_db(
386
+ self.pj,
387
+ parameters[cfg.SELECTED_SUBJECTS],
388
+ selected_observations,
389
+ parameters[cfg.SELECTED_BEHAVIORS],
390
+ time_interval=cfg.TIME_FULL_OBS,
391
+ )
392
+
393
+ self.statusBar().showMessage("Extracting sequences from media files")
394
+ QApplication.processEvents()
395
+
396
+ ffmpeg_extract_command: str = '"{ffmpeg_bin}" -ss {start} -i "{input_}" -y -t {duration} {codecs} '
397
+ mem_command: str = ""
398
+ for obs_id in selected_observations:
399
+ for nplayer in self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE]:
400
+ if not self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer]:
401
+ continue
402
+
403
+ duration1 = [] # in seconds
404
+ for mediaFile in self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer]:
405
+ duration1.append(self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.LENGTH][mediaFile])
406
+
407
+ for subject in parameters[cfg.SELECTED_SUBJECTS]:
408
+ for behavior in parameters[cfg.SELECTED_BEHAVIORS]:
409
+ cursor.execute(
410
+ "SELECT occurence FROM events WHERE observation = ? AND subject = ? AND code = ?",
411
+ (obs_id, subject, behavior),
412
+ )
413
+ rows = [{"occurence": util.float2decimal(r["occurence"])} for r in cursor.fetchall()]
414
+
415
+ behavior_state = project_functions.event_type(behavior, self.pj[cfg.ETHOGRAM])
416
+ if behavior_state in cfg.STATE_EVENT_TYPES and len(rows) % 2: # unpaired events
417
+ continue
418
+
419
+ for idx, row in enumerate(rows):
420
+ mediaFileIdx = [idx1 for idx1, x in enumerate(duration1) if row["occurence"] >= sum(duration1[0:idx1])][-1]
421
+
422
+ if "VIDEO" in items_to_extract.upper():
423
+ # check if media has video
424
+ has_video = False
425
+ try:
426
+ has_video = self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.HAS_VIDEO][
427
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]
428
+ ]
429
+ except Exception:
430
+ has_video = False
431
+ if not has_video:
432
+ if (
433
+ dialog.MessageDialog(
434
+ cfg.programName,
435
+ f"The media file {self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]} does not have a video stream",
436
+ ["Continue", "Abort"],
437
+ )
438
+ == "Abort"
439
+ ):
440
+ return
441
+ else:
442
+ continue
443
+
444
+ new_extension = ".mp4"
445
+ if items_to_extract == "Only video":
446
+ codecs = "-an"
447
+ else:
448
+ codecs = ""
449
+
450
+ if items_to_extract == "Only audio":
451
+ # check if media has audio
452
+ has_audio = False
453
+ try:
454
+ has_audio = self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.HAS_AUDIO][
455
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]
456
+ ]
457
+ except Exception:
458
+ has_audio = False
459
+ if not has_audio:
460
+ if (
461
+ dialog.MessageDialog(
462
+ cfg.programName,
463
+ f"The media file {self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]} does not have an audio stream",
464
+ ["Continue", "Abort"],
465
+ )
466
+ == "Abort"
467
+ ):
468
+ return
469
+ else:
470
+ continue
471
+
472
+ new_extension = ".wav"
473
+ codecs = "-vn"
474
+
475
+ if behavior_state in cfg.POINT_EVENT_TYPES:
476
+ globalStart = dec("0.000") if row["occurence"] < timeOffset else round(row["occurence"] - timeOffset, 3)
477
+ start = round(
478
+ row["occurence"]
479
+ - (timeOffset if timeOffset else 1) # if time offset is not set default = 1 s
480
+ - util.float2decimal(sum(duration1[0:mediaFileIdx]))
481
+ - self.pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET],
482
+ 3,
483
+ )
484
+ if start < timeOffset:
485
+ start = dec("0.000")
486
+
487
+ globalStop = round(row["occurence"] + timeOffset, 3)
488
+
489
+ stop = round(
490
+ row["occurence"]
491
+ + (timeOffset if timeOffset else 1) # if time offset is not set default = 1 s
492
+ - util.float2decimal(sum(duration1[0:mediaFileIdx]))
493
+ - self.pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET],
494
+ 3,
495
+ )
496
+
497
+ if behavior_state in cfg.STATE_EVENT_TYPES:
498
+ if idx % 2 == 0:
499
+ # check if stop is on same media file
500
+ if (
501
+ mediaFileIdx
502
+ != [idx1 for idx1, x in enumerate(duration1) if rows[idx + 1]["occurence"] >= sum(duration1[0:idx1])][
503
+ -1
504
+ ]
505
+ ):
506
+ response = dialog.MessageDialog(
507
+ cfg.programName,
508
+ (
509
+ "The event extends on 2 successive video. "
510
+ " At the moment it is not possible to extract this type of event.<br>"
511
+ ),
512
+ [cfg.OK, "Abort"],
513
+ )
514
+ if response == cfg.OK:
515
+ continue
516
+ if response == "Abort":
517
+ return
518
+
519
+ globalStart = dec("0.000") if row["occurence"] < timeOffset else round(row["occurence"] - timeOffset, 3)
520
+ start = round(
521
+ row["occurence"]
522
+ - timeOffset
523
+ - util.float2decimal(sum(duration1[0:mediaFileIdx]))
524
+ - self.pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET],
525
+ 3,
526
+ )
527
+ if start < timeOffset:
528
+ start = dec("0.000")
529
+
530
+ globalStop = round(rows[idx + 1]["occurence"] + timeOffset, 3)
531
+
532
+ stop = round(
533
+ rows[idx + 1]["occurence"]
534
+ + timeOffset
535
+ - util.float2decimal(sum(duration1[0:mediaFileIdx]))
536
+ - self.pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET],
537
+ 3,
538
+ )
539
+
540
+ # check if start after length of media
541
+ if (
542
+ start
543
+ > self.pj[cfg.OBSERVATIONS][obs_id][cfg.MEDIA_INFO][cfg.LENGTH][
544
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx]
545
+ ]
546
+ ):
547
+ continue
548
+
549
+ else:
550
+ continue
551
+
552
+ new_file_name = pl.Path(export_dir) / pl.Path(
553
+ (
554
+ f"{util.safeFileName(obs_id).replace(' ', '-')}_"
555
+ f"PLAYER{nplayer}_"
556
+ f"{util.safeFileName(subject).replace(' ', '-')}_"
557
+ f"{util.safeFileName(behavior)}_"
558
+ f"{globalStart}-{globalStop}"
559
+ f"{new_extension}"
560
+ )
561
+ ) # .with_suffix(new_extension)
562
+
563
+ if new_file_name.is_file():
564
+ if mem_command not in (cfg.OVERWRITE_ALL, cfg.SKIP_ALL):
565
+ mem_command = dialog.MessageDialog(
566
+ cfg.programName,
567
+ f"The file <b>{new_file_name}</b> already exists.",
568
+ [cfg.OVERWRITE, cfg.OVERWRITE_ALL, cfg.SKIP, cfg.SKIP_ALL, cfg.CANCEL],
569
+ )
570
+ if mem_command == cfg.CANCEL:
571
+ return
572
+ if "SKIP" in mem_command.upper():
573
+ continue
574
+
575
+ ffmpeg_command = ffmpeg_extract_command.format(
576
+ ffmpeg_bin=self.ffmpeg_bin,
577
+ input_=project_functions.full_path(
578
+ self.pj[cfg.OBSERVATIONS][obs_id][cfg.FILE][nplayer][mediaFileIdx],
579
+ self.projectFileName,
580
+ ),
581
+ start=start,
582
+ duration=stop - start,
583
+ codecs=codecs,
584
+ )
585
+
586
+ logging.debug(f'ffmpeg command: {ffmpeg_command} "{new_file_name}"')
587
+
588
+ p = subprocess.Popen(
589
+ f'{ffmpeg_command} "{new_file_name}"',
590
+ stdout=subprocess.PIPE,
591
+ stderr=subprocess.PIPE,
592
+ shell=True,
593
+ )
594
+ out, _ = p.communicate()
595
+
596
+ self.statusbar.showMessage(f"Media sequences extracted in {export_dir}", 0)