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
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)
|