boris-behav-obs 9.7.8__py3-none-any.whl → 9.7.9__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.
@@ -0,0 +1,225 @@
1
+ """
2
+ BORIS plugin
3
+
4
+ Export to FERAL (getferal.ai)
5
+ """
6
+
7
+ import pandas as pd
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from PySide6.QtWidgets import QFileDialog
12
+
13
+ # dependencies for CategoryDialog
14
+ from PySide6.QtWidgets import QListWidget, QListWidgetItem, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QDialog
15
+ from PySide6.QtCore import Qt
16
+
17
+
18
+ __version__ = "0.1.1"
19
+ __version_date__ = "2025-11-28"
20
+ __plugin_name__ = "Export observations to FERAL"
21
+ __author__ = "Olivier Friard - University of Torino - Italy"
22
+
23
+
24
+ class CategoryDialog(QDialog):
25
+ def __init__(self, items, parent=None):
26
+ super().__init__(parent)
27
+
28
+ self.setWindowTitle("Organize the videos in categories")
29
+
30
+ self.setModal(True)
31
+
32
+ # Main layout
33
+ main_layout = QVBoxLayout(self)
34
+ lists_layout = QHBoxLayout()
35
+
36
+ # All videos
37
+ self.list_unclassified = self._create_list_widget()
38
+ self.label_unclassified = QLabel("All videos")
39
+ col0_layout = QVBoxLayout()
40
+ col0_layout.addWidget(self.label_unclassified)
41
+ col0_layout.addWidget(self.list_unclassified)
42
+
43
+ self.list_cat1 = self._create_list_widget()
44
+ self.label_cat1 = QLabel("train")
45
+ col1_layout = QVBoxLayout()
46
+ col1_layout.addWidget(self.label_cat1)
47
+ col1_layout.addWidget(self.list_cat1)
48
+
49
+ self.list_cat2 = self._create_list_widget()
50
+ self.label_cat2 = QLabel("val")
51
+ col2_layout = QVBoxLayout()
52
+ col2_layout.addWidget(self.label_cat2)
53
+ col2_layout.addWidget(self.list_cat2)
54
+
55
+ self.list_cat3 = self._create_list_widget()
56
+ self.label_cat3 = QLabel("test")
57
+ col3_layout = QVBoxLayout()
58
+ col3_layout.addWidget(self.label_cat3)
59
+ col3_layout.addWidget(self.list_cat3)
60
+
61
+ self.list_cat4 = self._create_list_widget()
62
+ self.label_cat4 = QLabel("inference")
63
+ col4_layout = QVBoxLayout()
64
+ col4_layout.addWidget(self.label_cat4)
65
+ col4_layout.addWidget(self.list_cat4)
66
+
67
+ # Add all columns to the horizontal layout
68
+ lists_layout.addLayout(col0_layout)
69
+ lists_layout.addLayout(col1_layout)
70
+ lists_layout.addLayout(col2_layout)
71
+ lists_layout.addLayout(col3_layout)
72
+ lists_layout.addLayout(col4_layout)
73
+
74
+ main_layout.addLayout(lists_layout)
75
+
76
+ buttons_layout = QHBoxLayout()
77
+ self.btn_ok = QPushButton("OK")
78
+ self.btn_cancel = QPushButton("Cancel")
79
+
80
+ self.btn_ok.clicked.connect(self.accept)
81
+ self.btn_cancel.clicked.connect(self.reject)
82
+
83
+ buttons_layout.addStretch()
84
+ buttons_layout.addWidget(self.btn_ok)
85
+ buttons_layout.addWidget(self.btn_cancel)
86
+
87
+ main_layout.addLayout(buttons_layout)
88
+
89
+ # Populate "Unclassified" with input items
90
+ for text in items:
91
+ QListWidgetItem(text, self.list_unclassified)
92
+
93
+ def _create_list_widget(self):
94
+ """
95
+ Create a QListWidget ready for drag & drop.
96
+ """
97
+ lw = QListWidget()
98
+ lw.setSelectionMode(QListWidget.ExtendedSelection)
99
+ lw.setDragEnabled(True)
100
+ lw.setAcceptDrops(True)
101
+ lw.setDropIndicatorShown(True)
102
+ lw.setDragDropMode(QListWidget.DragDrop)
103
+ lw.setDefaultDropAction(Qt.MoveAction)
104
+ return lw
105
+
106
+ def get_categories(self):
107
+ """
108
+ Return the content of all categories as a dictionary of lists.
109
+ """
110
+
111
+ def collect(widget):
112
+ return [widget.item(i).text().rstrip("*") for i in range(widget.count())]
113
+
114
+ return {
115
+ "unclassified": collect(self.list_unclassified),
116
+ "train": collect(self.list_cat1),
117
+ "val": collect(self.list_cat2),
118
+ "test": collect(self.list_cat3),
119
+ "inference": collect(self.list_cat4),
120
+ }
121
+
122
+
123
+ def run(df: pd.DataFrame, project: dict):
124
+ """
125
+ Export observations to FERAL
126
+ See https://www.getferal.ai/ > Label Preparation
127
+ """
128
+
129
+ out: dict = {
130
+ "is_multilabel": False,
131
+ "splits": {
132
+ "train": [],
133
+ "val": [],
134
+ "test": [],
135
+ "inference": [],
136
+ },
137
+ }
138
+
139
+ log: list = []
140
+
141
+ # class names
142
+ class_names = {x: project["behaviors_conf"][x]["code"] for x in project["behaviors_conf"]}
143
+ out["class_names"] = class_names
144
+ reversed_class_names = {project["behaviors_conf"][x]["code"]: int(x) for x in project["behaviors_conf"]}
145
+ log.append(f"{class_names=}")
146
+
147
+ observations: list = sorted([x for x in project["observations"]])
148
+ log.append(f"Selected observation: {observations}")
149
+
150
+ labels: dict = {}
151
+ video_list: list = []
152
+ for observation_id in observations:
153
+ log.append("---")
154
+ log.append(observation_id)
155
+
156
+ # check number of media file in player #1
157
+ if len(project["observations"][observation_id]["file"]["1"]) != 1:
158
+ log.append(f"The observation {observation_id} contains more than one video")
159
+ continue
160
+
161
+ # check number of coded subjects
162
+ if len(set([x[1] for x in project["observations"][observation_id]["events"]])) > 1:
163
+ log.append(f"The observation {observation_id} contains more than one subject")
164
+ continue
165
+
166
+ media_file_path: str = project["observations"][observation_id]["file"]["1"][0]
167
+ media_file_name = str(Path(media_file_path).name)
168
+
169
+ # skip if no events
170
+ if not project["observations"][observation_id]["events"]:
171
+ video_list.append(media_file_name)
172
+ log.append(f"No events for observation {observation_id}")
173
+ continue
174
+ else:
175
+ video_list.append(media_file_name + "*")
176
+
177
+ # extract FPS
178
+ FPS = project["observations"][observation_id]["media_info"]["fps"][media_file_path]
179
+ log.append(f"{media_file_name} {FPS=}")
180
+ # extract media duration
181
+ duration = project["observations"][observation_id]["media_info"]["length"][media_file_path]
182
+ log.append(f"{media_file_name} {duration=}")
183
+
184
+ number_of_frames = int(duration / (1 / FPS))
185
+ log.append(f"{number_of_frames=}")
186
+
187
+ labels[media_file_name] = [0] * number_of_frames
188
+
189
+ for idx in range(number_of_frames):
190
+ t = idx * (1 / FPS)
191
+ behaviors = (
192
+ df[(df["Observation id"] == observation_id) & (df["Start (s)"] <= t) & (df["Stop (s)"] >= t)]["Behavior"].unique().tolist()
193
+ )
194
+ if len(behaviors) > 1:
195
+ log.append(f"The observation {observation_id} contains more than one behavior for frame {idx}")
196
+ del labels[media_file_name]
197
+ break
198
+ if behaviors:
199
+ behaviors_idx = reversed_class_names[behaviors[0]]
200
+ labels[media_file_name][idx] = behaviors_idx
201
+
202
+ out["labels"] = labels
203
+
204
+ # splits
205
+ dlg = CategoryDialog(video_list)
206
+
207
+ if dlg.exec(): # Dialog accepted
208
+ result = dlg.get_categories()
209
+ del result["unclassified"]
210
+ out["splits"] = result
211
+
212
+ filename, _ = QFileDialog.getSaveFileName(
213
+ None,
214
+ "Choose a file to save",
215
+ "", # start directory
216
+ "JSON files (*.json);;All files (*.*)",
217
+ )
218
+ if filename:
219
+ with open(filename, "w") as f_out:
220
+ f_out.write(json.dumps(out, separators=(",", ": "), indent=1))
221
+
222
+ else:
223
+ log.append("splits section missing")
224
+
225
+ return "\n".join(log)
boris/core.py CHANGED
@@ -1500,7 +1500,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
1500
1500
  logging.debug("previous media file")
1501
1501
 
1502
1502
  if self.playerType == cfg.MEDIA:
1503
- #if len(self.pj[cfg.OBSERVATIONS][self.observationId][cfg.FILE][cfg.PLAYER1]) == 1:
1503
+ # if len(self.pj[cfg.OBSERVATIONS][self.observationId][cfg.FILE][cfg.PLAYER1]) == 1:
1504
1504
  # self.seek_mediaplayer(dec(0))
1505
1505
  # return
1506
1506
 
@@ -1510,7 +1510,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
1510
1510
 
1511
1511
  elif self.dw_player[0].player.playlist_count == 1:
1512
1512
  self.seek_mediaplayer(dec(0))
1513
- #self.statusbar.showMessage("There is only one media file", 5000)
1513
+ # self.statusbar.showMessage("There is only one media file", 5000)
1514
1514
 
1515
1515
  if hasattr(self, "spectro"):
1516
1516
  self.spectro.memChunk = -1
@@ -4151,8 +4151,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
4151
4151
  called only in IPC mode
4152
4152
  """
4153
4153
  # check if eof reached
4154
- #print(f"{self.dw_player[0].player.playlist_pos=}")
4155
- #print(f"{self.dw_player[0].player.playlist_count=}")
4154
+ # print(f"{self.dw_player[0].player.playlist_pos=}")
4155
+ # print(f"{self.dw_player[0].player.playlist_count=}")
4156
4156
  if self.dw_player[0].player.eof_reached and self.dw_player[0].player.core_idle:
4157
4157
  logging.debug("end of playlist reached")
4158
4158
  if self.dw_player[0].player.playlist_pos is not None and self.dw_player[0].player.playlist_count is not None:
@@ -4220,8 +4220,11 @@ class MainWindow(QMainWindow, Ui_MainWindow):
4220
4220
 
4221
4221
  # sync players 2..8 if time diff >= 1 s
4222
4222
  if not math.isnan(ct) and not math.isnan(ct0):
4223
- if abs(ct0 - (ct + dec(self.pj[cfg.OBSERVATIONS][self.observationId][cfg.MEDIA_INFO][cfg.OFFSET][str(n_player + 1)]))) >= 1:
4224
- self.sync_time(n_player, ct0) # self.seek_mediaplayer(ct0, n_player)
4223
+ if (
4224
+ abs(ct0 - (ct + dec(self.pj[cfg.OBSERVATIONS][self.observationId][cfg.MEDIA_INFO][cfg.OFFSET][str(n_player + 1)])))
4225
+ >= 1
4226
+ ):
4227
+ self.sync_time(n_player, float(ct0)) # self.seek_mediaplayer(ct0, n_player)
4225
4228
 
4226
4229
  currentTimeOffset = dec(cumulative_time_pos + self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TIME_OFFSET])
4227
4230
 
@@ -5485,7 +5488,6 @@ class MainWindow(QMainWindow, Ui_MainWindow):
5485
5488
  if self.dw_player[0].player.playlist_pos == self.dw_player[0].player.playlist_count - 1:
5486
5489
  self.seek_mediaplayer(dec(0))
5487
5490
 
5488
-
5489
5491
  # check if player 1 is ended
5490
5492
  for i, dw in enumerate(self.dw_player):
5491
5493
  if (
@@ -5867,7 +5869,7 @@ def main():
5867
5869
  QMessageBox.warning(
5868
5870
  None,
5869
5871
  cfg.programName,
5870
- (f"This version of BORIS for macOS is still EXPERIMENTAL and should be used at your own risk."),
5872
+ ("This version of BORIS for macOS is still EXPERIMENTAL and should be used at your own risk."),
5871
5873
  QMessageBox.Ok | QMessageBox.Default,
5872
5874
  QMessageBox.NoButton,
5873
5875
  )
@@ -5898,4 +5900,3 @@ def main():
5898
5900
  del window
5899
5901
 
5900
5902
  sys.exit(return_code)
5901
-
@@ -26,7 +26,7 @@ from decimal import Decimal as dec
26
26
  import json
27
27
  from math import log2, floor
28
28
  import os
29
- import pathlib as pl
29
+ from pathlib import Path
30
30
  import socket
31
31
  import subprocess
32
32
  import sys
@@ -89,10 +89,10 @@ def export_observations_list_clicked(self):
89
89
  return
90
90
 
91
91
  output_format = cfg.FILE_NAME_SUFFIX[filter_]
92
- if pl.Path(file_name).suffix != "." + output_format:
93
- file_name = str(pl.Path(file_name)) + "." + output_format
92
+ if Path(file_name).suffix != "." + output_format:
93
+ file_name = str(Path(file_name)) + "." + output_format
94
94
  # check if file name with extension already exists
95
- if pl.Path(file_name).is_file():
95
+ if Path(file_name).is_file():
96
96
  if dialog.MessageDialog(cfg.programName, f"The file {file_name} already exists.", [cfg.CANCEL, cfg.OVERWRITE]) == cfg.CANCEL:
97
97
  return
98
98
 
@@ -131,7 +131,6 @@ def observations_list(self):
131
131
  else:
132
132
  close_observation(self)
133
133
 
134
-
135
134
  QtTest.QTest.qWait(1000)
136
135
 
137
136
  if result == cfg.OPEN:
@@ -602,7 +601,7 @@ def new_observation(self, mode: str = cfg.NEW, obsId: str = "") -> None:
602
601
  close_observation(self)
603
602
 
604
603
  observationWindow = observation.Observation(
605
- tmp_dir=self.ffmpeg_cache_dir if (self.ffmpeg_cache_dir and pl.Path(self.ffmpeg_cache_dir).is_dir()) else tempfile.gettempdir(),
604
+ tmp_dir=self.ffmpeg_cache_dir if (self.ffmpeg_cache_dir and Path(self.ffmpeg_cache_dir).is_dir()) else tempfile.gettempdir(),
606
605
  project_path=self.projectFileName,
607
606
  converters=self.pj.get(cfg.CONVERTERS, {}),
608
607
  time_format=self.timeFormat,
@@ -1315,7 +1314,7 @@ def check_creation_date(self) -> Tuple[int, dict]:
1315
1314
 
1316
1315
  if ret == 1: # use file creation time
1317
1316
  for media in not_tagged_media_list:
1318
- media_creation_time[media] = pl.Path(media).stat().st_ctime
1317
+ media_creation_time[media] = Path(media).stat().st_ctime
1319
1318
  return (0, media_creation_time) # OK use media file creation date/time
1320
1319
  else:
1321
1320
  return (1, {})
@@ -1856,10 +1855,9 @@ def initialize_new_media_observation(self) -> bool:
1856
1855
  # start timer for activating the main window
1857
1856
  self.main_window_activation_timer = QTimer()
1858
1857
  self.main_window_activation_timer.setInterval(500)
1859
- #self.main_window_activation_timer.timeout.connect(self.activateWindow)
1858
+ # self.main_window_activation_timer.timeout.connect(self.activateWindow)
1860
1859
  self.main_window_activation_timer.timeout.connect(self.activate_main_window)
1861
1860
  self.main_window_activation_timer.start()
1862
-
1863
1861
 
1864
1862
  for mediaFile in self.pj[cfg.OBSERVATIONS][self.observationId][cfg.FILE][n_player]:
1865
1863
  logging.debug(f"media file: {mediaFile}")
@@ -1904,7 +1902,7 @@ def initialize_new_media_observation(self) -> bool:
1904
1902
  self.dw_player[i].player.playlist_append(media_full_path)
1905
1903
 
1906
1904
  # add media file name to player window title
1907
- self.dw_player[i].setWindowTitle(f"Player #{i + 1} ({pl.Path(media_full_path).name})")
1905
+ self.dw_player[i].setWindowTitle(f"Player #{i + 1} ({Path(media_full_path).name})")
1908
1906
 
1909
1907
  # media duration cumuled in seconds
1910
1908
  self.dw_player[i].cumul_media_durations_sec = [round(dec(x / 1000), 3) for x in self.dw_player[i].cumul_media_durations]
@@ -2013,7 +2011,7 @@ def initialize_new_media_observation(self) -> bool:
2013
2011
  tmp_dir = self.ffmpeg_cache_dir if self.ffmpeg_cache_dir and os.path.isdir(self.ffmpeg_cache_dir) else tempfile.gettempdir()
2014
2012
 
2015
2013
  wav_file_path = (
2016
- pl.Path(tmp_dir) / pl.Path(self.dw_player[0].player.playlist[self.dw_player[0].player.playlist_pos]["filename"] + ".wav").name
2014
+ Path(tmp_dir) / Path(self.dw_player[0].player.playlist[self.dw_player[0].player.playlist_pos]["filename"] + ".wav").name
2017
2015
  )
2018
2016
 
2019
2017
  if not wav_file_path.is_file():
@@ -2029,7 +2027,7 @@ def initialize_new_media_observation(self) -> bool:
2029
2027
  tmp_dir = self.ffmpeg_cache_dir if self.ffmpeg_cache_dir and os.path.isdir(self.ffmpeg_cache_dir) else tempfile.gettempdir()
2030
2028
 
2031
2029
  wav_file_path = (
2032
- pl.Path(tmp_dir) / pl.Path(self.dw_player[0].player.playlist[self.dw_player[0].player.playlist_pos]["filename"] + ".wav").name
2030
+ Path(tmp_dir) / Path(self.dw_player[0].player.playlist[self.dw_player[0].player.playlist_pos]["filename"] + ".wav").name
2033
2031
  )
2034
2032
 
2035
2033
  if not wav_file_path.is_file():
@@ -2313,8 +2311,8 @@ def initialize_new_images_observation(self):
2313
2311
  sorted(
2314
2312
  list(
2315
2313
  set(
2316
- [str(x) for x in pl.Path(full_dir_path).glob(pattern)]
2317
- + [str(x) for x in pl.Path(full_dir_path).glob(pattern.upper())]
2314
+ [str(x) for x in Path(full_dir_path).glob(pattern)]
2315
+ + [str(x) for x in Path(full_dir_path).glob(pattern.upper())]
2318
2316
  )
2319
2317
  )
2320
2318
  )
@@ -2439,26 +2437,31 @@ def event2media_file_name(observation: dict, timestamp: dec) -> Optional[str]:
2439
2437
 
2440
2438
  def create_observations(self):
2441
2439
  """
2442
- Create observations from a media file directory
2440
+ Create observations from a directory of media files
2443
2441
  """
2444
- # print(self.pj[cfg.OBSERVATIONS])
2445
2442
 
2446
- dir_path = QFileDialog.getExistingDirectory(None, "Select directory", os.getenv("HOME"))
2447
- if not dir_path:
2443
+ if not (dir_path := QFileDialog.getExistingDirectory(None, "Select directory", os.getenv("HOME"))):
2448
2444
  return
2449
2445
 
2450
- dlg = dialog.Input_dialog(
2451
- label_caption="Set the following observation parameters",
2452
- elements_list=[
2446
+ elements_list: list = []
2447
+ if util.is_subdir(Path(dir_path), Path(self.projectFileName).parent):
2448
+ elements_list.append(("cb", "Use relative paths", False))
2449
+
2450
+ elements_list.extend(
2451
+ [
2453
2452
  ("cb", "Recurse the subdirectories", False),
2454
- ("cb", "Save the absolute media file path", True),
2455
2453
  ("cb", "Visualize spectrogram", False),
2456
2454
  ("cb", "Visualize waveform", False),
2457
2455
  ("cb", "Media creation date as offset", False),
2458
2456
  ("cb", "Close behaviors between videos", False),
2459
2457
  ("dsb", "Time offset (in seconds)", 0.0, 86400, 1, 0, 3),
2460
2458
  ("dsb", "Media scan sampling duration (in seconds)", 0.0, 86400, 1, 0, 3),
2461
- ],
2459
+ ]
2460
+ )
2461
+
2462
+ dlg = dialog.Input_dialog(
2463
+ label_caption="Set the following observation parameters",
2464
+ elements_list=elements_list,
2462
2465
  title="Observation parameters",
2463
2466
  )
2464
2467
  if not dlg.exec_():
@@ -2467,9 +2470,9 @@ def create_observations(self):
2467
2470
  file_count: int = 0
2468
2471
 
2469
2472
  if dlg.elements["Recurse the subdirectories"].isChecked():
2470
- files_list = pl.Path(dir_path).rglob("*")
2473
+ files_list = Path(dir_path).rglob("*")
2471
2474
  else:
2472
- files_list = pl.Path(dir_path).glob("*")
2475
+ files_list = Path(dir_path).glob("*")
2473
2476
 
2474
2477
  for file in files_list:
2475
2478
  if not file.is_file():
@@ -2479,22 +2482,25 @@ def create_observations(self):
2479
2482
  if not r.get("frames_number", 0):
2480
2483
  continue
2481
2484
 
2482
- if dlg.elements["Save the absolute media file path"].isChecked():
2483
- media_file = str(file)
2485
+ if "Use relative paths" in dlg.elements and dlg.elements["Use relative paths"].isChecked():
2486
+ media_file = str(file.relative_to(Path(self.projectFileName).parent))
2484
2487
  else:
2485
- try:
2486
- media_file = str(file.relative_to(pl.Path(self.projectFileName).parent))
2487
- except ValueError:
2488
- QMessageBox.critical(
2489
- self,
2490
- cfg.programName,
2491
- (
2492
- f"the media file <b>{file}</b> can not be relative to the project directory "
2493
- f"(<b>{pl.Path(self.projectFileName).parent}</b>)"
2494
- "<br><br>Aborting the creation of observations"
2495
- ),
2496
- )
2497
- return
2488
+ media_file = str(file)
2489
+
2490
+ # else:
2491
+ # try:
2492
+ # media_file = str(file.relative_to(Path(self.projectFileName).parent))
2493
+ # except ValueError:
2494
+ # QMessageBox.critical(
2495
+ # self,
2496
+ # cfg.programName,
2497
+ # (
2498
+ # f"the media file <b>{file}</b> can not be relative to the project directory "
2499
+ # f"(<b>{Path(self.projectFileName).parent}</b>)"
2500
+ # "<br><br>Aborting the creation of observations"
2501
+ # ),
2502
+ # )
2503
+ # return
2498
2504
 
2499
2505
  if media_file in self.pj[cfg.OBSERVATIONS]:
2500
2506
  QMessageBox.critical(
@@ -2535,4 +2541,6 @@ def create_observations(self):
2535
2541
  else:
2536
2542
  message: str = f"No media file were found in {dir_path}"
2537
2543
 
2544
+ menu_options.update_menu(self)
2545
+
2538
2546
  QMessageBox.information(self, cfg.programName, message)
boris/plugins.py CHANGED
@@ -24,6 +24,8 @@ import logging
24
24
  import numpy as np
25
25
  import pandas as pd
26
26
  from pathlib import Path
27
+ import copy
28
+ import inspect
27
29
 
28
30
  from PySide6.QtGui import QAction
29
31
  from PySide6.QtWidgets import QMessageBox
@@ -297,6 +299,7 @@ def run_plugin(self, plugin_name):
297
299
 
298
300
  logging.debug(f"{plugin_path=}")
299
301
 
302
+ # check if plugin file exists
300
303
  if not Path(plugin_path).is_file():
301
304
  QMessageBox.critical(self, cfg.programName, f"The plugin {plugin_path} was not found.")
302
305
  return
@@ -308,27 +311,7 @@ def run_plugin(self, plugin_name):
308
311
  if not selected_observations:
309
312
  return
310
313
 
311
- logging.info("preparing dataframe for plugin")
312
-
313
- message, df = project_functions.project2dataframe(self.pj, selected_observations)
314
- if message:
315
- logging.critical(message)
316
- QMessageBox.critical(self, cfg.programName, message)
317
- return
318
-
319
- logging.info("done")
320
-
321
- """
322
- logging.debug("dataframe info")
323
- logging.debug(f"{df.info()}")
324
- logging.debug(f"{df.head()}")
325
- """
326
-
327
- # filter the dataframe with parameters
328
- logging.info("filtering dataframe for plugin")
329
- filtered_df = plugin_df_filter(df, observations_list=selected_observations, parameters=parameters)
330
- logging.info("done")
331
-
314
+ # Python plugin
332
315
  if Path(plugin_path).suffix == ".py":
333
316
  # load plugin as module
334
317
  module_name = Path(plugin_path).stem
@@ -347,9 +330,48 @@ def run_plugin(self, plugin_name):
347
330
  f"{plugin_module.__plugin_name__} loaded v.{getattr(plugin_module, '__version__')} v. {getattr(plugin_module, '__version_date__')}"
348
331
  )
349
332
 
350
- # run plugin
351
- plugin_results = plugin_module.run(filtered_df)
333
+ # check arguments required by the run function of the plugin
334
+ dataframe_required = False
335
+ project_required = False
336
+ # for param in inspect.signature(plugin_module.run).parameters.values():
337
+ for name, annotation in inspect.getfullargspec(plugin_module.run).annotations.items():
338
+ if name == "df" and annotation is pd.DataFrame:
339
+ dataframe_required = True
340
+ if name == "project" and annotation is dict:
341
+ project_required = True
342
+
343
+ # create arguments for the plugin run function
344
+ plugin_kwargs: dict = {}
345
+
346
+ if dataframe_required:
347
+ logging.info("preparing dataframe for plugin")
348
+ message, df = project_functions.project2dataframe(self.pj, selected_observations)
349
+ if message:
350
+ logging.critical(message)
351
+ QMessageBox.critical(self, cfg.programName, message)
352
+ return
353
+ logging.info("done")
354
+
355
+ # filter the dataframe with parameters
356
+ logging.info("filtering dataframe for plugin")
357
+ filtered_df = plugin_df_filter(df, observations_list=selected_observations, parameters=parameters)
358
+ logging.info("done")
352
359
 
360
+ plugin_kwargs["df"] = filtered_df
361
+
362
+ if project_required:
363
+ pj_copy = copy.deepcopy(self.pj)
364
+
365
+ # remove unselected observations from project
366
+ for obs_id in self.pj[cfg.OBSERVATIONS]:
367
+ if obs_id not in selected_observations:
368
+ del pj_copy[cfg.OBSERVATIONS][obs_id]
369
+
370
+ plugin_kwargs["project"] = pj_copy
371
+
372
+ plugin_results = plugin_module.run(**plugin_kwargs)
373
+
374
+ # R plugin
353
375
  if Path(plugin_path).suffix in (".R", ".r"):
354
376
  try:
355
377
  from rpy2 import robjects
@@ -360,6 +382,21 @@ def run_plugin(self, plugin_name):
360
382
  QMessageBox.critical(self, cfg.programName, "The rpy2 Python module is not installed. R plugins cannot be used")
361
383
  return
362
384
 
385
+ logging.info("preparing dataframe for plugin")
386
+
387
+ message, df = project_functions.project2dataframe(self.pj, selected_observations)
388
+ if message:
389
+ logging.critical(message)
390
+ QMessageBox.critical(self, cfg.programName, message)
391
+ return
392
+
393
+ logging.info("done")
394
+
395
+ # filter the dataframe with parameters
396
+ logging.info("filtering dataframe for plugin")
397
+ filtered_df = plugin_df_filter(df, observations_list=selected_observations, parameters=parameters)
398
+ logging.info("done")
399
+
363
400
  # Read code from file
364
401
  try:
365
402
  with open(plugin_path, "r") as f:
boris/utilities.py CHANGED
@@ -126,6 +126,15 @@ if (sys.platform.startswith("win") or sys.platform.startswith("linux")) and ("-i
126
126
  sys.exit(5)
127
127
 
128
128
 
129
+ def is_subdir(a: Path, b: Path) -> bool:
130
+ """
131
+ Return True if directory A is inside directory B.
132
+ """
133
+ a = a.resolve()
134
+ b = b.resolve()
135
+ return a.is_relative_to(b)
136
+
137
+
129
138
  def test_mpv_ipc(socket_path: str = cfg.MPV_SOCKET) -> bool:
130
139
  """
131
140
  test if socket available
boris/version.py CHANGED
@@ -20,5 +20,5 @@ This file is part of BORIS.
20
20
 
21
21
  """
22
22
 
23
- __version__ = "9.7.8"
24
- __version_date__ = "2025-11-19"
23
+ __version__ = "9.7.9"
24
+ __version_date__ = "2025-11-27"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boris-behav-obs
3
- Version: 9.7.8
3
+ Version: 9.7.9
4
4
  Summary: BORIS - Behavioral Observation Research Interactive Software
5
5
  Author-email: Olivier Friard <olivier.friard@unito.it>
6
6
  License-Expression: GPL-3.0-only
@@ -51,7 +51,7 @@ It provides also some analysis tools like time budget and some plotting function
51
51
  <!-- The BO-RIS paper has more than [![BORIS citations counter](https://penelope.unito.it/friard/boris_scopus_citations.png) citations](https://www.boris.unito.it/citations) in peer-reviewed scientific publications. -->
52
52
 
53
53
 
54
- The BORIS paper has more than 2442 citations in peer-reviewed scientific publications.
54
+ The BORIS paper has more than 2443 citations in peer-reviewed scientific publications.
55
55
 
56
56
 
57
57
 
@@ -16,7 +16,7 @@ boris/connections.py,sha256=KsC17LnS4tRM6O3Nu3mD1H9kQ7uYhhad9229jXfGF94,19774
16
16
  boris/converters.py,sha256=n6gDM9x2hS-ZOoHLruiifuXxnC7ERsUukiFokhHZPoQ,11678
17
17
  boris/converters_ui.py,sha256=uu7LOBV_fKv2DBdOqsqPwjGsjgONr5ODBoscAA-EP48,9900
18
18
  boris/cooccurence.py,sha256=tVERC-V8MWjWHlGEfDuu08iS94qjt4do-38jwI62QaY,10367
19
- boris/core.py,sha256=gTlVvKH_khobEZ0aBwtZaKFH3BIitR8XjqkAz93Vr6c,234295
19
+ boris/core.py,sha256=PfpWAsA7vWVPSYkCcZ79xsPgwOc-dP4V-yCD9XgybtQ,234375
20
20
  boris/core_qrc.py,sha256=Hz51Xw70ZIlDbYB281nfGtCm43_ItYhamMu2T5X8Tu8,639882
21
21
  boris/core_ui.py,sha256=uDAI9mbadBe2mSsgugzNfRHxASc6Mu5kUf5tB9CZCjY,77445
22
22
  boris/db_functions.py,sha256=TfCJ0Hq0pTFOKrZz3RzdvnR-NKCmrPHU2qL9BSXeeGQ,13379
@@ -47,7 +47,7 @@ boris/modifiers_coding_map.py,sha256=oT56ZY_PXhEJsMoblEsyNMAPbDpv7ZMOCnvmt7Ibx_Y
47
47
  boris/mpv.py,sha256=EfzIHjPbgewG4w3smEtqEUPZoVwYmMQkL4Q8ZyW-a58,76410
48
48
  boris/mpv2.py,sha256=IUI4t4r9GYX7G5OXTjd3RhMMOkDdfal_15buBgksLsk,92152
49
49
  boris/observation.py,sha256=K-Xi99BMLSohx6pPLKk-Eqi56fLWDFLScHxXKkoFAlg,57507
50
- boris/observation_operations.py,sha256=YYbaaecwd2C0zxQYAxes-rUx-XY6aQ94hzkPazmPcFw,106339
50
+ boris/observation_operations.py,sha256=HPPat_-6c_Fu0cFs0wQD86AIE7g7eKHutOGi_xexMAM,106581
51
51
  boris/observation_ui.py,sha256=DAnU94QBNvkLuHT6AxTwqSk_D_n6VUhSl8PexZv_dUk,33309
52
52
  boris/observations_list.py,sha256=NqwECGHtHYmKhSe-qCfqPmJ24SSfzlXvIXS2i3op_zE,10591
53
53
  boris/otx_parser.py,sha256=70QvilzFHXbjAHR88YH0aEXJ3xxheLS5fZGgHFHGpNE,16367
@@ -59,7 +59,7 @@ boris/plot_events.py,sha256=tKiUWH0TNSkK7xz5Vf0tAD3KiuAalv6UZEVtOnoFpWY,24059
59
59
  boris/plot_events_rt.py,sha256=xJmjwqhQxCN4FDBYRQ0O2eHm356Rbexzr3m1qTefMDU,8326
60
60
  boris/plot_spectrogram_rt.py,sha256=wDhnkqwjd2UfCxrfOejOUxoNOqfMNo6vo1JSvYgM-2A,10925
61
61
  boris/plot_waveform_rt.py,sha256=RNXhcBzRKnoGoVjRAHsVvOaj0BJbbI2cpCMjMUiGqX0,7534
62
- boris/plugins.py,sha256=sn2r8kMxkzaO8kNhem-cTlTBrym9MlFPyRT9Av9rHGg,15603
62
+ boris/plugins.py,sha256=AzjKLPsqBk-K8wPM3HXe4w-sNaDU1bQm2XEoLr4Zx0k,17234
63
63
  boris/preferences.py,sha256=YcGBaXRfimH4jpFLfcUAgCAGESXSLhB6cli_cg4cDl0,21902
64
64
  boris/preferences_ui.py,sha256=zkmbQbkb0WqhPyMtnU-DU9Y2enSph_r-LnwsmEOgzkk,35090
65
65
  boris/project.py,sha256=4dE4nqo8at1lz9KykprF5Rr62R78yu4vBrTfde1gQ-g,86438
@@ -77,8 +77,8 @@ boris/synthetic_time_budget.py,sha256=3Eb9onMLmgqCLd50CuxV9L8RV2ESzfaMWvPK_bXUMM
77
77
  boris/time_budget_functions.py,sha256=SbGyTF2xySEqBdRJZeWFTirruvK3r6pwM6e4Gz17W1s,52186
78
78
  boris/time_budget_widget.py,sha256=z-tyITBtIz-KH1H2OdMB5a8x9QQLK7Wu96-zkC6NVDA,43213
79
79
  boris/transitions.py,sha256=okyDCO-Vn4p_Fixd8cGiSIaUhUxG5ePIOqGSuP52g_c,12246
80
- boris/utilities.py,sha256=hZF3yTNgjKN057VGLvy6gLYZsjV8b_DHCDGy6_DERd8,58621
81
- boris/version.py,sha256=lcCIuAdOq-j1yLj0CPlt2bedzEeQSaKQemxANe9BGpA,787
80
+ boris/utilities.py,sha256=Ae3YjXkulJYQxbE4Fpvle4tX2nA_qJQhAn980sTFHqw,58805
81
+ boris/version.py,sha256=WD0mTHLHZshmktAU-U6dY_d9wm09W0c_v9t3wdvW5-I,787
82
82
  boris/video_equalizer.py,sha256=cm2JXe1eAPkqDzxYB2OMKyuveMBdq4a9-gD1bBorbn4,5823
83
83
  boris/video_equalizer_ui.py,sha256=1CG3s79eM4JAbaCx3i1ILZXLceb41_gGXlOLNfpBgnw,10142
84
84
  boris/video_operations.py,sha256=YlwcX3rgVsv6h78ylQYS9ITZs5MsXT8h0XsyaBU5mN8,11015
@@ -86,6 +86,7 @@ boris/view_df.py,sha256=fhcNMFFVbQ4ZTpPwN_4Bg66DDIoV25zaPryZUsOxsKg,3338
86
86
  boris/view_df_ui.py,sha256=CaMeRH_vQ00CTDDFQn73ZZaS-r8BSTWpL-dMCFqzJ_Q,2775
87
87
  boris/write_event.py,sha256=xC-dUjgPS4-tvrQfvyL7O_xi-tyQI7leSiSV8_x8hgg,23817
88
88
  boris/analysis_plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
89
+ boris/analysis_plugins/_export_to_feral.py,sha256=_eirrreEn3Udp4POBG8cA1Gno4e-x_LFYXfvDhy11fg,7417
89
90
  boris/analysis_plugins/_latency.py,sha256=9kCdFDtb5Zdao1xFpioi_exm_IxyGm6RlY3Opn6GUFo,2030
90
91
  boris/analysis_plugins/irr_cohen_kappa.py,sha256=OqmivIE6i1hTcFVMp0EtY0Sr7C1Jm9t0D4IKbDfTJ7U,4268
91
92
  boris/analysis_plugins/irr_cohen_kappa_with_modifiers.py,sha256=DtzFLRToR9GdkmWYDcCmpSvxHGpguVp-_n8F-t7ND7c,4461
@@ -101,9 +102,9 @@ boris/portion/dict.py,sha256=uNM-LEY52CZ2VNMMW_C9QukoyTvPlQf8vcbGa1lQBHI,11281
101
102
  boris/portion/func.py,sha256=mSQr20YS1ug7R1fRqBg8LifjtXDRvJ6Kjc3WOeL9P34,2172
102
103
  boris/portion/interval.py,sha256=sOlj3MAGGaB-JxCkigS-n3qw0fY7TANAsXv1pavr8J4,19931
103
104
  boris/portion/io.py,sha256=kpq44pw3xnIyAlPwaR5qRHKRdZ72f8HS9YVIWs5k2pk,6367
104
- boris_behav_obs-9.7.8.dist-info/licenses/LICENSE.TXT,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
105
- boris_behav_obs-9.7.8.dist-info/METADATA,sha256=1ehrEM-hNzY-uiRRZn8x2xRwnMUV7cG8CmbW5U-MS0o,5203
106
- boris_behav_obs-9.7.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
107
- boris_behav_obs-9.7.8.dist-info/entry_points.txt,sha256=k__8XvFi4vaA4QFvQehCZjYkKmZH34HSAJI2iYCWrMs,52
108
- boris_behav_obs-9.7.8.dist-info/top_level.txt,sha256=fJSgm62S7WesiwTorGbOO4nNN0yzgZ3klgfGi3Px4qI,6
109
- boris_behav_obs-9.7.8.dist-info/RECORD,,
105
+ boris_behav_obs-9.7.9.dist-info/licenses/LICENSE.TXT,sha256=WJ7YI-moTFb-uVrFjnzzhGJrnL9P2iqQe8NuED3hutI,35141
106
+ boris_behav_obs-9.7.9.dist-info/METADATA,sha256=aZnGCR1F6i4L1MqgrpzSsraISOgHo_rcBGDzx1xvS7k,5203
107
+ boris_behav_obs-9.7.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
108
+ boris_behav_obs-9.7.9.dist-info/entry_points.txt,sha256=k__8XvFi4vaA4QFvQehCZjYkKmZH34HSAJI2iYCWrMs,52
109
+ boris_behav_obs-9.7.9.dist-info/top_level.txt,sha256=fJSgm62S7WesiwTorGbOO4nNN0yzgZ3klgfGi3Px4qI,6
110
+ boris_behav_obs-9.7.9.dist-info/RECORD,,