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.
- boris/__init__.py +26 -0
- boris/__main__.py +25 -0
- boris/about.py +143 -0
- boris/add_modifier.py +635 -0
- boris/add_modifier_ui.py +303 -0
- boris/advanced_event_filtering.py +455 -0
- boris/analysis_plugins/__init__.py +0 -0
- boris/analysis_plugins/_latency.py +59 -0
- boris/analysis_plugins/irr_cohen_kappa.py +109 -0
- boris/analysis_plugins/irr_cohen_kappa_with_modifiers.py +112 -0
- boris/analysis_plugins/irr_weighted_cohen_kappa.py +157 -0
- boris/analysis_plugins/irr_weighted_cohen_kappa_with_modifiers.py +162 -0
- boris/analysis_plugins/list_of_dataframe_columns.py +22 -0
- boris/analysis_plugins/number_of_occurences.py +22 -0
- boris/analysis_plugins/number_of_occurences_by_independent_variable.py +54 -0
- boris/analysis_plugins/time_budget.py +61 -0
- boris/behav_coding_map_creator.py +1110 -0
- boris/behavior_binary_table.py +305 -0
- boris/behaviors_coding_map.py +239 -0
- boris/boris_cli.py +340 -0
- boris/cmd_arguments.py +49 -0
- boris/coding_pad.py +280 -0
- boris/config.py +785 -0
- boris/config_file.py +356 -0
- boris/connections.py +409 -0
- boris/converters.py +333 -0
- boris/converters_ui.py +225 -0
- boris/cooccurence.py +250 -0
- boris/core.py +5901 -0
- boris/core_qrc.py +15958 -0
- boris/core_ui.py +1107 -0
- boris/db_functions.py +324 -0
- boris/dev.py +134 -0
- boris/dialog.py +1108 -0
- boris/duration_widget.py +238 -0
- boris/edit_event.py +245 -0
- boris/edit_event_ui.py +233 -0
- boris/event_operations.py +1040 -0
- boris/events_cursor.py +61 -0
- boris/events_snapshots.py +596 -0
- boris/exclusion_matrix.py +141 -0
- boris/export_events.py +1006 -0
- boris/export_observation.py +1203 -0
- boris/external_processes.py +332 -0
- boris/geometric_measurement.py +941 -0
- boris/gui_utilities.py +135 -0
- boris/image_overlay.py +72 -0
- boris/import_observations.py +242 -0
- boris/ipc_mpv.py +325 -0
- boris/irr.py +634 -0
- boris/latency.py +244 -0
- boris/measurement_widget.py +161 -0
- boris/media_file.py +115 -0
- boris/menu_options.py +213 -0
- boris/modifier_coding_map_creator.py +1013 -0
- boris/modifiers_coding_map.py +157 -0
- boris/mpv.py +2016 -0
- boris/mpv2.py +2193 -0
- boris/observation.py +1453 -0
- boris/observation_operations.py +2538 -0
- boris/observation_ui.py +679 -0
- boris/observations_list.py +337 -0
- boris/otx_parser.py +442 -0
- boris/param_panel.py +201 -0
- boris/param_panel_ui.py +305 -0
- boris/player_dock_widget.py +198 -0
- boris/plot_data_module.py +536 -0
- boris/plot_events.py +634 -0
- boris/plot_events_rt.py +237 -0
- boris/plot_spectrogram_rt.py +316 -0
- boris/plot_waveform_rt.py +230 -0
- boris/plugins.py +431 -0
- boris/portion/__init__.py +31 -0
- boris/portion/const.py +95 -0
- boris/portion/dict.py +365 -0
- boris/portion/func.py +52 -0
- boris/portion/interval.py +581 -0
- boris/portion/io.py +181 -0
- boris/preferences.py +510 -0
- boris/preferences_ui.py +770 -0
- boris/project.py +2007 -0
- boris/project_functions.py +2041 -0
- boris/project_import_export.py +1096 -0
- boris/project_ui.py +794 -0
- boris/qrc_boris.py +10389 -0
- boris/qrc_boris5.py +2579 -0
- boris/select_modifiers.py +312 -0
- boris/select_observations.py +210 -0
- boris/select_subj_behav.py +286 -0
- boris/state_events.py +197 -0
- boris/subjects_pad.py +106 -0
- boris/synthetic_time_budget.py +290 -0
- boris/time_budget_functions.py +1136 -0
- boris/time_budget_widget.py +1039 -0
- boris/transitions.py +365 -0
- boris/utilities.py +1810 -0
- boris/version.py +24 -0
- boris/video_equalizer.py +159 -0
- boris/video_equalizer_ui.py +248 -0
- boris/video_operations.py +310 -0
- boris/view_df.py +104 -0
- boris/view_df_ui.py +75 -0
- boris/write_event.py +538 -0
- boris_behav_obs-9.7.7.dist-info/METADATA +139 -0
- boris_behav_obs-9.7.7.dist-info/RECORD +109 -0
- boris_behav_obs-9.7.7.dist-info/WHEEL +5 -0
- boris_behav_obs-9.7.7.dist-info/entry_points.txt +2 -0
- boris_behav_obs-9.7.7.dist-info/licenses/LICENSE.TXT +674 -0
- boris_behav_obs-9.7.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,332 @@
|
|
|
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 os
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
import logging
|
|
27
|
+
|
|
28
|
+
from PySide6.QtWidgets import QFileDialog, QMessageBox, QInputDialog
|
|
29
|
+
from PySide6.QtCore import (
|
|
30
|
+
Qt,
|
|
31
|
+
QProcess,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from . import config as cfg
|
|
35
|
+
from . import dialog
|
|
36
|
+
from . import utilities as util
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ffmpeg_process(self, action: str):
|
|
40
|
+
"""
|
|
41
|
+
launch ffmpeg process with QProcess
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
action (str): "reencode_resize, rotate, merge, video_spectrogram
|
|
45
|
+
"""
|
|
46
|
+
if action not in ("reencode_resize", "rotate", "merge", "video_spectrogram"):
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
def readStdOutput(idx):
|
|
50
|
+
"""
|
|
51
|
+
read stdout and stderr form qprocess and display them
|
|
52
|
+
"""
|
|
53
|
+
self.processes_widget.label.setText(
|
|
54
|
+
(
|
|
55
|
+
"This operation can be long. Be patient...\n"
|
|
56
|
+
"In the meanwhile you can continue to use BORIS\n\n"
|
|
57
|
+
f"Done: {self.processes_widget.number_of_files - len(self.processes)} of {self.processes_widget.number_of_files}"
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# self.processes_widget.lwi.clear()
|
|
62
|
+
std_out = self.processes[idx - 1][0].readAllStandardOutput().data().decode("utf-8")
|
|
63
|
+
if std_out:
|
|
64
|
+
self.processes_widget.lwi.addItems((f"{Path(self.processes[idx - 1][1][2]).name}: {std_out}",))
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
std_err = self.processes[idx - 1][0].readAllStandardError().data().decode("utf-8")
|
|
68
|
+
if std_err:
|
|
69
|
+
self.processes_widget.lwi.addItems((f"{pl.Path(self.processes[idx - 1][1][2]).name}: ERROR: {std_err}",))
|
|
70
|
+
self.flag_ffmpeg_error = True
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
self.processes_widget.lwi.scrollToBottom()
|
|
74
|
+
|
|
75
|
+
def qprocess_finished(idx):
|
|
76
|
+
"""
|
|
77
|
+
function triggered when process finished
|
|
78
|
+
"""
|
|
79
|
+
if self.processes:
|
|
80
|
+
del self.processes[idx - 1]
|
|
81
|
+
if self.processes:
|
|
82
|
+
# start new process
|
|
83
|
+
self.processes[-1][0].start(self.processes[-1][1][0], self.processes[-1][1][1])
|
|
84
|
+
else:
|
|
85
|
+
self.processes_widget.label.setText(
|
|
86
|
+
(f"Done: {self.processes_widget.number_of_files - len(self.processes)} of {self.processes_widget.number_of_files}")
|
|
87
|
+
)
|
|
88
|
+
"""
|
|
89
|
+
self.processes_widget.hide()
|
|
90
|
+
del self.processes_widget
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
if self.processes:
|
|
94
|
+
QMessageBox.warning(self, cfg.programName, "BORIS is already running some job.")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if action == "merge":
|
|
98
|
+
msg = "Select two or more media files to merge"
|
|
99
|
+
file_type = "Media files (*)"
|
|
100
|
+
else:
|
|
101
|
+
msg = f"Select one or more video files to {action.replace('_', ' and ')}"
|
|
102
|
+
file_type = "Video files (*)"
|
|
103
|
+
file_names, _ = QFileDialog().getOpenFileNames(self, msg, "", file_type)
|
|
104
|
+
|
|
105
|
+
if not file_names:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if action == "reencode_resize":
|
|
109
|
+
current_bitrate = 10_000_000 # default 10 Mb/s
|
|
110
|
+
current_resolution = 1024
|
|
111
|
+
|
|
112
|
+
r = util.accurate_media_analysis(self.ffmpeg_bin, file_names[0])
|
|
113
|
+
if "error" in r:
|
|
114
|
+
QMessageBox.warning(self, cfg.programName, f"{file_names[0]}. {r['error']}")
|
|
115
|
+
elif r["has_video"]:
|
|
116
|
+
current_bitrate = r.get("bitrate", None)
|
|
117
|
+
if current_bitrate is None:
|
|
118
|
+
current_bitrate = -1
|
|
119
|
+
else:
|
|
120
|
+
current_bitrate = round(current_bitrate / 1024 / 1024) # Convert to Mb/s
|
|
121
|
+
current_resolution = int(r["resolution"].split("x")[0]) if r["resolution"] is not None else None
|
|
122
|
+
|
|
123
|
+
ib = dialog.Input_dialog(
|
|
124
|
+
"Set the parameters for re-encoding / resizing",
|
|
125
|
+
[
|
|
126
|
+
("sb", "Horizontal resolution (in pixel)", 352, 3840, 100, current_resolution),
|
|
127
|
+
("sb", "Video quality (bitrate Mb/s)", 1, 1000, 1, current_bitrate),
|
|
128
|
+
],
|
|
129
|
+
)
|
|
130
|
+
if not ib.exec_():
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
if len(file_names) > 1:
|
|
134
|
+
if (
|
|
135
|
+
dialog.MessageDialog(
|
|
136
|
+
cfg.programName,
|
|
137
|
+
"All the selected video files will be re-encoded / resized with these parameters",
|
|
138
|
+
[cfg.OK, cfg.CANCEL],
|
|
139
|
+
)
|
|
140
|
+
== cfg.CANCEL
|
|
141
|
+
):
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
horiz_resol = ib.elements["Horizontal resolution (in pixel)"].value()
|
|
145
|
+
video_quality = ib.elements["Video quality (bitrate Mb/s)"].value()
|
|
146
|
+
|
|
147
|
+
if action == "merge":
|
|
148
|
+
if len(file_names) == 1:
|
|
149
|
+
QMessageBox.critical(self, cfg.programName, "Select more than one file")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
file_extensions = [] # check extension of 1st media file
|
|
153
|
+
file_list_lst = []
|
|
154
|
+
for file_name in file_names:
|
|
155
|
+
file_list_lst.append(f"file '{file_name}'")
|
|
156
|
+
file_extensions.append(Path(file_name).suffix)
|
|
157
|
+
if len(set(file_extensions)) > 1:
|
|
158
|
+
QMessageBox.critical(self, cfg.programName, "All media files must have the same format")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
while True:
|
|
162
|
+
output_file_name, _ = QFileDialog().getSaveFileName(self, "Output file name", "", "*")
|
|
163
|
+
if output_file_name == "":
|
|
164
|
+
return
|
|
165
|
+
if Path(output_file_name).suffix != file_extensions[0]:
|
|
166
|
+
QMessageBox.warning(
|
|
167
|
+
self,
|
|
168
|
+
cfg.programName,
|
|
169
|
+
(
|
|
170
|
+
"The extension of output file must be the same than the extension of input files "
|
|
171
|
+
f"(<b>{file_extensions[0]}</b>).<br>You selected a {Path(output_file_name).suffix} file."
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
# temp file for list of media file to merge
|
|
178
|
+
with tempfile.NamedTemporaryFile() as tmp:
|
|
179
|
+
file_list = tmp.name
|
|
180
|
+
with open(file_list, "w") as f_out:
|
|
181
|
+
f_out.write("\n".join(file_list_lst))
|
|
182
|
+
|
|
183
|
+
if action == "rotate":
|
|
184
|
+
rotation_items = ("Rotate 90 clockwise", "Rotate 90 counter clockwise", "rotate 180")
|
|
185
|
+
|
|
186
|
+
rotation, ok = QInputDialog.getItem(self, "Rotate media file(s)", "Type of rotation", rotation_items, 0, False)
|
|
187
|
+
|
|
188
|
+
if not ok:
|
|
189
|
+
return
|
|
190
|
+
rotation_idx = rotation_items.index(rotation) + 1
|
|
191
|
+
|
|
192
|
+
# check if processed files already exist
|
|
193
|
+
if action in ("reencode_resize", "rotate"):
|
|
194
|
+
files_list = []
|
|
195
|
+
for file_name in file_names:
|
|
196
|
+
if action == "reencode_resize":
|
|
197
|
+
fn = f"{file_name}.re-encoded.{horiz_resol}px.{video_quality}Mb.avi"
|
|
198
|
+
|
|
199
|
+
if action == "rotate":
|
|
200
|
+
fn = f"{file_name}.rotated{['', '90', '-90', '180'][rotation_idx]}.avi"
|
|
201
|
+
|
|
202
|
+
if os.path.isfile(fn):
|
|
203
|
+
files_list.append(fn)
|
|
204
|
+
|
|
205
|
+
if files_list:
|
|
206
|
+
response = dialog.MessageDialog(
|
|
207
|
+
cfg.programName,
|
|
208
|
+
"Some file(s) already exist.\n\n" + "\n".join(files_list),
|
|
209
|
+
[cfg.OVERWRITE_ALL, cfg.CANCEL],
|
|
210
|
+
)
|
|
211
|
+
if response == cfg.CANCEL:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
self.processes_widget = dialog.Info_widget()
|
|
215
|
+
self.processes_widget.resize(700, 300)
|
|
216
|
+
|
|
217
|
+
self.processes_widget.setWindowFlags(Qt.WindowStaysOnTopHint)
|
|
218
|
+
match action:
|
|
219
|
+
case "reencode_resize":
|
|
220
|
+
self.processes_widget.setWindowTitle("Re-encoding and resizing with FFmpeg")
|
|
221
|
+
case "rotate":
|
|
222
|
+
self.processes_widget.setWindowTitle("Rotating the video with FFmpeg")
|
|
223
|
+
case "merge":
|
|
224
|
+
self.processes_widget.setWindowTitle("Merging media files")
|
|
225
|
+
case "video_spectrogram":
|
|
226
|
+
self.processes_widget.setWindowTitle("Creating a video spectrogram")
|
|
227
|
+
|
|
228
|
+
self.processes_widget.label.setText("This operation can be long. Be patient...\nIn the meanwhile you can continue to use BORIS\n\n")
|
|
229
|
+
self.processes_widget.number_of_files = len(file_names)
|
|
230
|
+
self.processes_widget.show()
|
|
231
|
+
|
|
232
|
+
match action:
|
|
233
|
+
case "merge":
|
|
234
|
+
# ffmpeg -f concat -safe 0 -i join_video.txt -c copy output.mp4
|
|
235
|
+
args = ["-hide_banner", "-y", "-f", "concat", "-safe", "0", "-i", file_list, "-c", "copy", output_file_name]
|
|
236
|
+
self.processes.append([QProcess(self), [self.ffmpeg_bin, args, output_file_name]])
|
|
237
|
+
self.processes[-1][0].setProcessChannelMode(QProcess.MergedChannels)
|
|
238
|
+
self.processes[-1][0].readyReadStandardOutput.connect(lambda: readStdOutput(len(self.processes)))
|
|
239
|
+
self.processes[-1][0].readyReadStandardError.connect(lambda: readStdOutput(len(self.processes)))
|
|
240
|
+
self.processes[-1][0].finished.connect(lambda: qprocess_finished(len(self.processes)))
|
|
241
|
+
|
|
242
|
+
self.processes[-1][0].start(self.processes[-1][1][0], self.processes[-1][1][1])
|
|
243
|
+
|
|
244
|
+
case "video_spectrogram":
|
|
245
|
+
# ffmpeg -i video.mp4 -filter_complex showspectrum=mode=combined:color=intensity:slide=1:scale=cbrt -y -acodec copy output.mp4
|
|
246
|
+
for file_name in sorted(file_names, reverse=True):
|
|
247
|
+
output_file_name = str(Path(file_name).with_suffix(f".spectrogram{Path(file_name).suffix}"))
|
|
248
|
+
args = [
|
|
249
|
+
"-hide_banner",
|
|
250
|
+
"-y",
|
|
251
|
+
"-i",
|
|
252
|
+
file_name,
|
|
253
|
+
"-filter_complex",
|
|
254
|
+
"showspectrum=mode=combined:color=intensity:slide=1:scale=cbrt",
|
|
255
|
+
"-acodec",
|
|
256
|
+
"copy",
|
|
257
|
+
output_file_name,
|
|
258
|
+
]
|
|
259
|
+
self.processes.append([QProcess(self), [self.ffmpeg_bin, args, output_file_name]])
|
|
260
|
+
self.processes[-1][0].setProcessChannelMode(QProcess.MergedChannels)
|
|
261
|
+
self.processes[-1][0].readyReadStandardOutput.connect(lambda: readStdOutput(len(self.processes)))
|
|
262
|
+
self.processes[-1][0].readyReadStandardError.connect(lambda: readStdOutput(len(self.processes)))
|
|
263
|
+
self.processes[-1][0].finished.connect(lambda: qprocess_finished(len(self.processes)))
|
|
264
|
+
|
|
265
|
+
self.processes[-1][0].start(self.processes[-1][1][0], self.processes[-1][1][1])
|
|
266
|
+
|
|
267
|
+
case "reencode_resize" | "rotate":
|
|
268
|
+
for file_name in sorted(file_names, reverse=True):
|
|
269
|
+
if action == "reencode_resize":
|
|
270
|
+
args = [
|
|
271
|
+
"-hide_banner",
|
|
272
|
+
"-y",
|
|
273
|
+
"-i",
|
|
274
|
+
f"{file_name}",
|
|
275
|
+
"-vf",
|
|
276
|
+
f"scale={horiz_resol}:-1",
|
|
277
|
+
"-b:v",
|
|
278
|
+
f"{video_quality * 1024 * 1024}",
|
|
279
|
+
f"{file_name}.re-encoded.{horiz_resol}px.{video_quality}Mb.avi",
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
if action == "rotate":
|
|
283
|
+
# check bitrate
|
|
284
|
+
r = util.accurate_media_analysis(self.ffmpeg_bin, file_name)
|
|
285
|
+
if "error" not in r and r["bitrate"] is not None:
|
|
286
|
+
current_bitrate = r["bitrate"]
|
|
287
|
+
else:
|
|
288
|
+
current_bitrate = 10_000_000
|
|
289
|
+
|
|
290
|
+
if rotation_idx in (1, 2):
|
|
291
|
+
args = [
|
|
292
|
+
"-hide_banner",
|
|
293
|
+
"-y",
|
|
294
|
+
"-i",
|
|
295
|
+
f"{file_name}",
|
|
296
|
+
"-vf",
|
|
297
|
+
f"transpose={rotation_idx}",
|
|
298
|
+
"-codec:a",
|
|
299
|
+
"copy",
|
|
300
|
+
"-b:v",
|
|
301
|
+
f"{current_bitrate}",
|
|
302
|
+
f"{file_name}.rotated{['', '90', '-90'][rotation_idx]}.avi",
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
if rotation_idx == 3: # 180
|
|
306
|
+
args = [
|
|
307
|
+
"-hide_banner",
|
|
308
|
+
"-y",
|
|
309
|
+
"-i",
|
|
310
|
+
f"{file_name}",
|
|
311
|
+
"-vf",
|
|
312
|
+
"transpose=2,transpose=2",
|
|
313
|
+
"-codec:a",
|
|
314
|
+
"copy",
|
|
315
|
+
"-b:v",
|
|
316
|
+
f"{current_bitrate}",
|
|
317
|
+
f"{file_name}.rotated180.avi",
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
logging.debug("Launch process")
|
|
321
|
+
logging.debug(f"{self.ffmpeg_bin} {' '.join(args)}")
|
|
322
|
+
|
|
323
|
+
self.processes.append([QProcess(self), [self.ffmpeg_bin, args, file_name]])
|
|
324
|
+
|
|
325
|
+
## FFmpeg output the work in progress on stderr
|
|
326
|
+
self.processes[-1][0].setProcessChannelMode(QProcess.MergedChannels)
|
|
327
|
+
self.processes[-1][0].readyReadStandardOutput.connect(lambda: readStdOutput(len(self.processes)))
|
|
328
|
+
# self.processes[-1][0].readyReadStandardError.connect(lambda: readStdOutput(len(self.processes)))
|
|
329
|
+
|
|
330
|
+
self.processes[-1][0].finished.connect(lambda: qprocess_finished(len(self.processes)))
|
|
331
|
+
|
|
332
|
+
self.processes[-1][0].start(self.processes[-1][1][0], self.processes[-1][1][1])
|