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.
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
@@ -0,0 +1,941 @@
1
+ """
2
+ BORIS
3
+ Behavioral Observation Research Interactive Software
4
+ Copyright 2012-2025 Olivier Friard
5
+
6
+ This file is part of BORIS.
7
+
8
+ BORIS is free software; you can redistribute it and/or modify
9
+ it under the terms of the GNU General Public License as published by
10
+ the Free Software Foundation; either version 3 of the License, or
11
+ any later version.
12
+
13
+ BORIS is distributed in the hope that it will be useful,
14
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ GNU General Public License for more details.
17
+
18
+ You should have received a copy of the GNU General Public License
19
+ along with this program; if not see <http://www.gnu.org/licenses/>.
20
+
21
+ """
22
+
23
+ import logging
24
+ import io
25
+ import pandas as pd
26
+ import pathlib as pl
27
+
28
+ try:
29
+ import pyreadr
30
+
31
+ flag_pyreadr_loaded = True
32
+ except ModuleNotFoundError:
33
+ flag_pyreadr_loaded = False
34
+
35
+
36
+ from PySide6.QtCore import QPoint, Qt, Signal, QEvent
37
+ from PySide6.QtGui import QColor, QPainter, QPolygon, QPixmap, QAction, QPen
38
+ from PySide6.QtWidgets import (
39
+ QApplication,
40
+ QCheckBox,
41
+ QFileDialog,
42
+ QHBoxLayout,
43
+ QLabel,
44
+ QLineEdit,
45
+ QMessageBox,
46
+ QTableWidget,
47
+ QTableWidgetItem,
48
+ QPushButton,
49
+ QRadioButton,
50
+ QVBoxLayout,
51
+ QColorDialog,
52
+ QSpacerItem,
53
+ QSizePolicy,
54
+ QDialog,
55
+ )
56
+
57
+ from typing import List
58
+
59
+ from . import config as cfg
60
+ from . import dialog, menu_options
61
+ from . import utilities as util
62
+
63
+
64
+ class wgMeasurement(QDialog):
65
+ """
66
+ widget for geometric measurements
67
+ """
68
+
69
+ closeSignal = Signal()
70
+ send_event_signal = Signal(QEvent)
71
+ reload_image_signal = Signal()
72
+ save_picture_signal = Signal(str)
73
+ mark_color: str = cfg.ACTIVE_MEASUREMENTS_COLOR
74
+ flag_saved = True # store if measurements are saved
75
+ draw_mem: dict = {}
76
+ mem_points: list = [] # memory of clicked points
77
+ mem_video: list = [] # memory of clicked points
78
+
79
+ def __init__(self):
80
+ super().__init__()
81
+
82
+ self.setWindowTitle("Geometric measurements")
83
+
84
+ vbox = QVBoxLayout(self)
85
+
86
+ self.rb_point = QRadioButton("Point (left click)", clicked=self.rb_clicked)
87
+ vbox.addWidget(self.rb_point)
88
+
89
+ self.rb_polyline = QRadioButton("Polyline (left click for vertices, right click to finish)", clicked=self.rb_clicked)
90
+ vbox.addWidget(self.rb_polyline)
91
+
92
+ self.rb_polygon = QRadioButton(
93
+ "Polygon (left click for Polygon vertices, right click to close the polygon)", clicked=self.rb_clicked
94
+ )
95
+ vbox.addWidget(self.rb_polygon)
96
+
97
+ self.rb_angle = QRadioButton("Angle (vertex: left click, segments: right click)", clicked=self.rb_clicked)
98
+ vbox.addWidget(self.rb_angle)
99
+
100
+ self.rb_oriented_angle = QRadioButton("Oriented angle (vertex: left click, segments: right click)", clicked=self.rb_clicked)
101
+ vbox.addWidget(self.rb_oriented_angle)
102
+
103
+ hbox = QHBoxLayout()
104
+ self.cbPersistentMeasurements = QCheckBox("Measurements are persistent")
105
+ self.cbPersistentMeasurements.setChecked(True)
106
+ hbox.addWidget(self.cbPersistentMeasurements)
107
+
108
+ # color chooser
109
+ self.bt_color_chooser = QPushButton("Choose color of marks", clicked=self.choose_marks_color)
110
+ self.bt_color_chooser.setStyleSheet(f"QWidget {{background-color:{self.mark_color}}}")
111
+ hbox.addWidget(self.bt_color_chooser)
112
+
113
+ hbox.addItem(QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
114
+
115
+ vbox.addLayout(hbox)
116
+
117
+ vbox.addWidget(QLabel("<b>Scale</b>"))
118
+
119
+ hbox1 = QHBoxLayout()
120
+ self.lbRef = QLabel("Reference")
121
+ hbox1.addWidget(self.lbRef)
122
+ self.lbPx = QLabel("Pixels")
123
+ hbox1.addWidget(self.lbPx)
124
+ vbox.addLayout(hbox1)
125
+
126
+ hbox2 = QHBoxLayout()
127
+ self.leRef = QLineEdit()
128
+ self.leRef.setText("1")
129
+ hbox2.addWidget(self.leRef)
130
+ self.lePx = QLineEdit()
131
+ self.lePx.setText("1")
132
+ hbox2.addWidget(self.lePx)
133
+ vbox.addLayout(hbox2)
134
+
135
+ self.pte = QTableWidget()
136
+ self.pte.verticalHeader().hide()
137
+
138
+ # header
139
+ self.measurements_header = [
140
+ "Player",
141
+ "media file name",
142
+ "Time",
143
+ "Frame index",
144
+ "Type of measurement",
145
+ "x",
146
+ "y",
147
+ "Distance",
148
+ "Area",
149
+ "Angle",
150
+ "Coordinates",
151
+ ]
152
+ self.pte.setColumnCount(len(self.measurements_header))
153
+ self.pte.setHorizontalHeaderLabels(self.measurements_header)
154
+
155
+ self.pte.setSelectionBehavior(QTableWidget.SelectRows)
156
+ self.pte.setSelectionMode(QTableWidget.MultiSelection)
157
+
158
+ self.pte.setContextMenuPolicy(Qt.ActionsContextMenu)
159
+
160
+ self.action = QAction("Delete measurement")
161
+ self.action.triggered.connect(self.delete_measurement)
162
+
163
+ self.pte.addAction(self.action)
164
+
165
+ vbox.addWidget(self.pte)
166
+
167
+ self.status_lb = QLabel()
168
+ vbox.addWidget(self.status_lb)
169
+
170
+ hbox3 = QHBoxLayout()
171
+ hbox3.addItem(QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
172
+ self.pb_clear = QPushButton("Clear measurements", clicked=self.pbClear_clicked)
173
+ hbox3.addWidget(self.pb_clear)
174
+
175
+ self.pb_save_picture = QPushButton("Save current picture", clicked=self.pb_save_picture_clicked)
176
+ hbox3.addWidget(self.pb_save_picture)
177
+
178
+ # disabled for now
179
+ self.pb_save_all_pictures = QPushButton("Save all pictures", clicked=self.pb_save_all_pictures_clicked)
180
+ hbox3.addWidget(self.pb_save_all_pictures)
181
+
182
+ self.pb_save = QPushButton("Save results", clicked=self.pb_save_clicked)
183
+ hbox3.addWidget(self.pb_save)
184
+ self.pb_close = QPushButton(cfg.CLOSE, clicked=self.pbClose_clicked)
185
+ hbox3.addWidget(self.pb_close)
186
+ vbox.addLayout(hbox3)
187
+
188
+ self.installEventFilter(self)
189
+
190
+ def rb_clicked(self):
191
+ """
192
+ radiobutton clicked, all points in memory are cleared
193
+ """
194
+ self.mem_points = []
195
+ self.mem_video = []
196
+
197
+ self.reload_image_signal.emit()
198
+
199
+ def eventFilter(self, receiver, event):
200
+ """
201
+ send event (if keypress) to main window
202
+ """
203
+ if event.type() == QEvent.KeyPress:
204
+ self.send_event_signal.emit(event)
205
+ return True
206
+ else:
207
+ return False
208
+
209
+ def pb_save_picture_clicked(self):
210
+ """
211
+ ask to save the current frame
212
+ """
213
+ self.save_picture_signal.emit("current")
214
+
215
+ def pb_save_all_pictures_clicked(self):
216
+ """
217
+ ask to save all frames with measurements
218
+ """
219
+ self.save_picture_signal.emit("all")
220
+
221
+ def delete_measurement(self):
222
+ """
223
+ delete the selected measurement(s)
224
+ """
225
+
226
+ if not self.pte.selectedItems():
227
+ return
228
+
229
+ rows_to_delete: list = []
230
+ for item in self.pte.selectedItems():
231
+ if item.row() not in rows_to_delete:
232
+ rows_to_delete.append(item.row())
233
+
234
+ elements_to_delete = []
235
+ for row in sorted(rows_to_delete, reverse=True):
236
+ player = int(self.pte.item(row, 0).text())
237
+ frame_idx = int(self.pte.item(row, 2).text())
238
+ obj_type = self.pte.item(row, 3).text()
239
+ coord = eval(self.pte.item(row, 9).text())
240
+
241
+ if frame_idx in self.draw_mem:
242
+ for idx, element in enumerate(self.draw_mem[frame_idx]):
243
+ if (element["player"] == player - 1) and (element["object_type"] == obj_type) and (element["coordinates"] == coord):
244
+ elements_to_delete.append((frame_idx, idx))
245
+
246
+ self.pte.removeRow(row)
247
+ self.pte.flag_saved = False
248
+
249
+ for frame_idx, idx in sorted(elements_to_delete, reverse=True):
250
+ self.draw_mem[frame_idx].pop(idx)
251
+
252
+ self.reload_image_signal.emit()
253
+
254
+ def choose_marks_color(self):
255
+ """
256
+ show the color chooser dialog
257
+ """
258
+ cd = QColorDialog()
259
+ cd.setWindowFlags(Qt.WindowStaysOnTopHint)
260
+ cd.setOptions(QColorDialog.ShowAlphaChannel | QColorDialog.DontUseNativeDialog)
261
+ cd.setCurrentColor(QColor(self.mark_color))
262
+
263
+ if cd.exec_():
264
+ new_color = cd.currentColor()
265
+ self.bt_color_chooser.setStyleSheet(f"QWidget {{background-color:{new_color.name()}}}")
266
+ self.mark_color = new_color.name()
267
+
268
+ def closeEvent(self, event):
269
+ """
270
+ Intercept the close event to check if measurements are saved
271
+ """
272
+
273
+ logging.debug("close event")
274
+
275
+ if not self.flag_saved:
276
+ response = dialog.MessageDialog(
277
+ cfg.programName,
278
+ "The current measurements are not saved. Do you want to save the measurement results before closing?",
279
+ (cfg.YES, cfg.NO, cfg.CANCEL),
280
+ )
281
+ if response == cfg.YES:
282
+ if self.pb_save_clicked():
283
+ event.ignore()
284
+ return
285
+ if response == cfg.CANCEL:
286
+ event.ignore()
287
+ return
288
+
289
+ self.flag_saved: bool = True
290
+ self.draw_mem: dict = {}
291
+ self.closeSignal.emit()
292
+
293
+ def pbClear_clicked(self):
294
+ """
295
+ clear measurements draw and results
296
+ """
297
+
298
+ if not self.flag_saved:
299
+ response = dialog.MessageDialog(
300
+ cfg.programName,
301
+ "Confirm clearing",
302
+ (cfg.YES, cfg.CANCEL),
303
+ )
304
+ if response == cfg.CANCEL:
305
+ return
306
+
307
+ self.draw_mem: dict = {}
308
+ self.mem_points: list = []
309
+ self.mem_video: list = []
310
+
311
+ self.pte.clear()
312
+ self.pte.setColumnCount(len(self.measurements_header))
313
+ self.pte.setRowCount(0)
314
+ self.pte.setHorizontalHeaderLabels(self.measurements_header)
315
+ self.flag_saved = True
316
+
317
+ self.reload_image_signal.emit()
318
+
319
+ def pbClose_clicked(self):
320
+ """
321
+ Close button
322
+ """
323
+ logging.debug("close function")
324
+ self.close()
325
+
326
+ def pb_save_clicked(self) -> bool:
327
+ """
328
+ Save measurements results
329
+ """
330
+
331
+ file_formats = [cfg.TSV, cfg.CSV, cfg.ODS, cfg.XLSX, cfg.HTML, cfg.PANDAS_DF]
332
+ if flag_pyreadr_loaded:
333
+ file_formats.append(cfg.RDS)
334
+
335
+ # default file name
336
+ media_file_list: list = []
337
+ for row in range(self.pte.rowCount()):
338
+ media_file_list.append(self.pte.item(row, 1).text())
339
+
340
+ if len(set(media_file_list)) == 1:
341
+ default_file_name = str(pl.Path(media_file_list[0]).with_suffix(""))
342
+ else:
343
+ default_file_name = ""
344
+
345
+ file_name, filter_ = QFileDialog().getSaveFileName(self, "Save geometric measurements", default_file_name, ";;".join(file_formats))
346
+ if not file_name:
347
+ return True
348
+
349
+ # add correct file extension if not present
350
+ if pl.Path(file_name).suffix != f".{cfg.FILE_NAME_SUFFIX[filter_]}":
351
+ file_name = str(pl.Path(file_name)) + "." + cfg.FILE_NAME_SUFFIX[filter_]
352
+ # check if file with new extension already exists
353
+ if pl.Path(file_name).is_file():
354
+ if (
355
+ dialog.MessageDialog(cfg.programName, f"The file {file_name} already exists.", (cfg.CANCEL, cfg.OVERWRITE))
356
+ == cfg.CANCEL
357
+ ):
358
+ return True
359
+
360
+ plain_text: str = "\t".join(self.measurements_header) + "\n"
361
+ for row in range(self.pte.rowCount()):
362
+ row_content: list = []
363
+ for col in range(self.pte.columnCount()):
364
+ row_content.append(self.pte.item(row, col).text())
365
+ plain_text += "\t".join(row_content) + "\n"
366
+
367
+ plain_text = plain_text[:-1]
368
+
369
+ df = pd.read_csv(io.StringIO(plain_text), sep="\t")
370
+
371
+ try:
372
+ if filter_ == cfg.ODS:
373
+ df.to_excel(file_name, engine="odf", sheet_name="Geometric measurements", index=False, na_rep="NA")
374
+ self.flag_saved = True
375
+ if filter_ == cfg.XLSX:
376
+ df.to_excel(file_name, sheet_name="Geometric measurements", index=False, na_rep="NA")
377
+ self.flag_saved = True
378
+ if filter_ == cfg.HTML:
379
+ df.to_html(file_name, index=False, na_rep="NA")
380
+ self.flag_saved = True
381
+ if filter_ == cfg.CSV:
382
+ df.to_csv(file_name, index=False, sep=",", na_rep="NA")
383
+ self.flag_saved = True
384
+ if filter_ == cfg.TSV:
385
+ df.to_csv(file_name, index=False, sep="\t", na_rep="NA")
386
+ self.flag_saved = True
387
+ if filter_ == cfg.PANDAS_DF:
388
+ df.to_pickle(file_name)
389
+ if filter_ == cfg.RDS:
390
+ pyreadr.write_rds(file_name, df)
391
+
392
+ except Exception:
393
+ QMessageBox.warning(self, cfg.programName, "An error occured during saving the measurement results")
394
+ return True
395
+ return False # everything OK
396
+
397
+
398
+ def show_widget(self) -> None:
399
+ """
400
+ active the geometric measurement widget
401
+ """
402
+
403
+ def close_measurement_widget():
404
+ """
405
+ close the geometric measurement widget
406
+ """
407
+
408
+ logging.debug("close_measurement_widget")
409
+
410
+ if self.observationId and self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.MEDIA:
411
+ for n_player, dw in enumerate(self.dw_player):
412
+ dw.frame_viewer.clear()
413
+ dw.stack.setCurrentIndex(cfg.VIDEO_VIEWER)
414
+ dw.setWindowTitle(f"Player #{n_player + 1}")
415
+ self.actionPlay.setEnabled(True)
416
+
417
+ if self.observationId and self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.IMAGES:
418
+ for dw in self.dw_player:
419
+ self.extract_frame(dw)
420
+
421
+ self.geometric_measurements_mode = False
422
+ self.measurement_w.draw_mem = {}
423
+
424
+ self.measurement_w.close()
425
+ menu_options.update_menu(self)
426
+
427
+ self.geometric_measurements_mode = True
428
+ self.pause_video()
429
+
430
+ menu_options.update_menu(self)
431
+
432
+ self.actionPlay.setEnabled(False)
433
+
434
+ self.measurement_w = wgMeasurement()
435
+ # self.measurement_w.setWindowFlags(Qt.WindowStaysOnTopHint)
436
+ self.measurement_w.closeSignal.connect(close_measurement_widget)
437
+ self.measurement_w.send_event_signal.connect(self.signal_from_widget)
438
+ self.measurement_w.reload_image_signal.connect(self.reload_frame)
439
+ self.measurement_w.save_picture_signal.connect(self.save_picture_with_measurements)
440
+ self.measurement_w.draw_mem = {}
441
+
442
+ self.measurement_w.show()
443
+
444
+ for dw in self.dw_player:
445
+ dw.setWindowTitle("Geometric measurements")
446
+ dw.stack.setCurrentIndex(cfg.PICTURE_VIEWER)
447
+ self.extract_frame(dw)
448
+
449
+
450
+ def draw_point(self, x: int, y: int, color: str, n_player: int = 0) -> None:
451
+ """
452
+ draw point on frame-by-frame image
453
+ """
454
+
455
+ logging.debug("draw_point function")
456
+
457
+ RADIUS = 6
458
+
459
+ pixmap_copy = self.dw_player[n_player].frame_viewer.pixmap().copy()
460
+
461
+ painter = QPainter(pixmap_copy)
462
+ try:
463
+ painter.setPen(QPen(QColor(color), 1))
464
+ painter.drawEllipse(QPoint(x, y), RADIUS, RADIUS)
465
+ # cross inside circle
466
+ painter.drawLine(x - RADIUS, y, x + RADIUS, y)
467
+ painter.drawLine(x, y - RADIUS, x, y + RADIUS)
468
+ finally:
469
+ painter.end()
470
+
471
+ self.dw_player[n_player].frame_viewer.setPixmap(pixmap_copy)
472
+ self.dw_player[n_player].frame_viewer.update()
473
+
474
+
475
+ def draw_line(self, x1: int, y1: int, x2: int, y2: int, color: str, n_player: int = 0) -> None:
476
+ """
477
+ draw line on frame-by-frame image
478
+ """
479
+
480
+ pixmap_copy = self.dw_player[n_player].frame_viewer.pixmap().copy()
481
+ painter = QPainter(pixmap_copy)
482
+
483
+ try:
484
+ painter.setPen(QColor(color))
485
+ painter.drawLine(x1, y1, x2, y2)
486
+ finally:
487
+ painter.end()
488
+
489
+ self.dw_player[n_player].frame_viewer.setPixmap(pixmap_copy)
490
+ self.dw_player[n_player].frame_viewer.update()
491
+
492
+
493
+ def append_results(self, results: list) -> None:
494
+ """
495
+ append results to measurements table
496
+ """
497
+ self.measurement_w.pte.setRowCount(self.measurement_w.pte.rowCount() + 1)
498
+ for idx, x in enumerate(results):
499
+ item = QTableWidgetItem()
500
+ item.setText(str(x))
501
+ item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
502
+ self.measurement_w.pte.setItem(self.measurement_w.pte.rowCount() - 1, idx, item)
503
+
504
+
505
+ def image_clicked(self, n_player: int, event) -> None:
506
+ """
507
+ Geometric measurements on image
508
+
509
+ Args:
510
+ n_player (int): id of clicked player
511
+ event (Qevent): event (mousepressed)
512
+ """
513
+
514
+ logging.debug("function image_clicked")
515
+
516
+ if not self.geometric_measurements_mode:
517
+ return
518
+
519
+ if self.mem_player != -1 and n_player != self.mem_player:
520
+ self.mem_player = n_player
521
+ return
522
+
523
+ self.mem_player = n_player
524
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.MEDIA:
525
+ if self.dw_player[n_player].player.estimated_frame_number is not None:
526
+ current_frame = self.dw_player[n_player].player.estimated_frame_number
527
+ else:
528
+ current_frame = cfg.NA
529
+ elif self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.IMAGES:
530
+ current_frame = self.image_idx
531
+
532
+ if not (hasattr(self, "measurement_w") and (self.measurement_w is not None) and (self.measurement_w.isVisible())):
533
+ return
534
+
535
+ x, y = event.pos().x(), event.pos().y()
536
+
537
+ logging.debug(f"clicked on {x} {y}")
538
+
539
+ # convert label coordinates in pixmap coordinates
540
+ pixmap_x = int(x - (self.dw_player[n_player].frame_viewer.width() - self.dw_player[n_player].frame_viewer.pixmap().width()) / 2)
541
+ pixmap_y = int(y - (self.dw_player[n_player].frame_viewer.height() - self.dw_player[n_player].frame_viewer.pixmap().height()) / 2)
542
+
543
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.MEDIA:
544
+ # convert pixmap coordinates in video coordinates
545
+ x_video = round((pixmap_x / self.dw_player[n_player].frame_viewer.pixmap().width()) * self.dw_player[n_player].player.width)
546
+ y_video = round((pixmap_y / self.dw_player[n_player].frame_viewer.pixmap().height()) * self.dw_player[n_player].player.height)
547
+ elif self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.IMAGES:
548
+ original_width = QPixmap(self.images_list[self.image_idx]).size().width()
549
+ original_height = QPixmap(self.images_list[self.image_idx]).size().height()
550
+ x_video = round((pixmap_x / self.dw_player[n_player].frame_viewer.pixmap().width()) * original_width)
551
+ y_video = round((pixmap_y / self.dw_player[n_player].frame_viewer.pixmap().height()) * original_height)
552
+
553
+ if not (
554
+ 0 <= pixmap_x <= self.dw_player[n_player].frame_viewer.pixmap().width()
555
+ and 0 <= pixmap_y <= self.dw_player[n_player].frame_viewer.pixmap().height()
556
+ ):
557
+ self.measurement_w.status_lb.setText("<b>The click is outside the video area</b>")
558
+ return
559
+
560
+ self.measurement_w.status_lb.clear()
561
+
562
+ # media file name
563
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.MEDIA:
564
+ fn = self.dw_player[n_player - 1].player.playlist[self.dw_player[n_player - 1].player.playlist_pos]["filename"]
565
+
566
+ # check if media file path contained in media list
567
+ if [True for x in self.pj[cfg.OBSERVATIONS][self.observationId]["file"].values() if fn in x]:
568
+ media_file_name = self.dw_player[n_player - 1].player.playlist[self.dw_player[n_player - 1].player.playlist_pos]["filename"]
569
+ else:
570
+ media_file_name = str(pl.Path(fn).relative_to(pl.Path(self.projectFileName).parent))
571
+
572
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.IMAGES:
573
+ # check if pictures dir path is relative
574
+ if str(pl.Path(self.images_list[current_frame]).parent) not in self.pj[cfg.OBSERVATIONS][self.observationId].get(
575
+ cfg.DIRECTORIES_LIST, []
576
+ ):
577
+ media_file_name = str(pl.Path(self.images_list[current_frame]).relative_to(pl.Path(self.projectFileName).parent))
578
+ else:
579
+ media_file_name = self.images_list[current_frame]
580
+
581
+ # point
582
+ if self.measurement_w.rb_point.isChecked():
583
+ if event.button() == Qt.LeftButton:
584
+ draw_point(self, pixmap_x, pixmap_y, self.measurement_w.mark_color, n_player)
585
+ if current_frame not in self.measurement_w.draw_mem:
586
+ self.measurement_w.draw_mem[current_frame] = []
587
+
588
+ self.measurement_w.draw_mem[current_frame].append(
589
+ {
590
+ "player": n_player,
591
+ "object_type": cfg.POINT_OBJECT,
592
+ "color": self.measurement_w.mark_color,
593
+ "coordinates": [(x_video, y_video)],
594
+ }
595
+ )
596
+
597
+ append_results(
598
+ self,
599
+ (
600
+ n_player + 1,
601
+ media_file_name,
602
+ f"{self.getLaps():.03f}",
603
+ current_frame,
604
+ cfg.POINT_OBJECT,
605
+ x_video,
606
+ y_video,
607
+ cfg.NA,
608
+ cfg.NA,
609
+ cfg.NA,
610
+ str([(x_video, y_video)]),
611
+ ),
612
+ )
613
+
614
+ self.measurement_w.flag_saved = False
615
+
616
+ # angle
617
+ elif self.measurement_w.rb_angle.isChecked() or self.measurement_w.rb_oriented_angle.isChecked():
618
+ if event.button() == Qt.LeftButton:
619
+ draw_point(self, pixmap_x, pixmap_y, self.measurement_w.mark_color, n_player)
620
+ self.measurement_w.mem_points = [(pixmap_x, pixmap_y)]
621
+ self.measurement_w.mem_video = [(x_video, y_video)]
622
+
623
+ if event.button() == Qt.RightButton and len(self.measurement_w.mem_points):
624
+ draw_point(self, pixmap_x, pixmap_y, self.measurement_w.mark_color, n_player)
625
+ draw_line(
626
+ self,
627
+ self.measurement_w.mem_points[0][0],
628
+ self.measurement_w.mem_points[0][1],
629
+ pixmap_x,
630
+ pixmap_y,
631
+ self.measurement_w.mark_color,
632
+ n_player,
633
+ )
634
+
635
+ self.measurement_w.mem_points.append((pixmap_x, pixmap_y))
636
+ self.measurement_w.mem_video.append((x_video, y_video))
637
+
638
+ if len(self.measurement_w.mem_points) == 3:
639
+ if self.measurement_w.rb_angle.isChecked():
640
+ angle = util.angle(self.measurement_w.mem_points[0], self.measurement_w.mem_points[1], self.measurement_w.mem_points[2])
641
+ object_type = cfg.ANGLE_OBJECT
642
+ else: # oriented angle
643
+ angle = util.oriented_angle(
644
+ self.measurement_w.mem_points[0], self.measurement_w.mem_points[1], self.measurement_w.mem_points[2]
645
+ )
646
+ object_type = cfg.ORIENTED_ANGLE_OBJECT
647
+
648
+ append_results(
649
+ self,
650
+ (
651
+ n_player + 1,
652
+ media_file_name,
653
+ f"{self.getLaps():.03f}",
654
+ current_frame,
655
+ object_type,
656
+ cfg.NA,
657
+ cfg.NA,
658
+ cfg.NA,
659
+ cfg.NA,
660
+ round(
661
+ angle,
662
+ 1,
663
+ ),
664
+ str(self.measurement_w.mem_video),
665
+ ),
666
+ )
667
+
668
+ self.measurement_w.flag_saved = False
669
+ if current_frame not in self.measurement_w.draw_mem:
670
+ self.measurement_w.draw_mem[current_frame] = []
671
+
672
+ self.measurement_w.draw_mem[current_frame].append(
673
+ {
674
+ "player": n_player,
675
+ "object_type": object_type,
676
+ "color": self.measurement_w.mark_color,
677
+ "coordinates": self.measurement_w.mem_video,
678
+ }
679
+ )
680
+
681
+ self.measurement_w.mem_points, self.measurement_w.mem_video = [], []
682
+
683
+ # polygon
684
+ elif self.measurement_w.rb_polygon.isChecked():
685
+ if event.button() == Qt.LeftButton:
686
+ draw_point(self, pixmap_x, pixmap_y, self.measurement_w.mark_color, n_player)
687
+ if len(self.measurement_w.mem_points):
688
+ draw_line(
689
+ self,
690
+ self.measurement_w.mem_points[-1][0],
691
+ self.measurement_w.mem_points[-1][1],
692
+ pixmap_x,
693
+ pixmap_y,
694
+ self.measurement_w.mark_color,
695
+ n_player,
696
+ )
697
+ self.measurement_w.mem_points.append((pixmap_x, pixmap_y))
698
+ self.measurement_w.mem_video.append((x_video, y_video))
699
+
700
+ if event.button() == Qt.RightButton and len(self.measurement_w.mem_points) >= 2:
701
+ # close polygon
702
+ draw_line(
703
+ self,
704
+ self.measurement_w.mem_points[-1][0],
705
+ self.measurement_w.mem_points[-1][1],
706
+ self.measurement_w.mem_points[0][0],
707
+ self.measurement_w.mem_points[0][1],
708
+ self.measurement_w.mark_color,
709
+ n_player,
710
+ )
711
+ if current_frame not in self.measurement_w.draw_mem:
712
+ self.measurement_w.draw_mem[current_frame] = []
713
+
714
+ self.measurement_w.draw_mem[current_frame].append(
715
+ {
716
+ "player": n_player,
717
+ "object_type": cfg.POLYGON_OBJECT,
718
+ "color": self.measurement_w.mark_color,
719
+ "coordinates": self.measurement_w.mem_video,
720
+ }
721
+ )
722
+
723
+ area = util.polygon_area(self.measurement_w.mem_video)
724
+ try:
725
+ area = area / (float(self.measurement_w.lePx.text()) ** 2) * float(self.measurement_w.leRef.text()) ** 2
726
+ except Exception:
727
+ QMessageBox.critical(
728
+ None,
729
+ cfg.programName,
730
+ "Check reference and pixel values! Values must be numeric.",
731
+ QMessageBox.Ok | QMessageBox.Default,
732
+ QMessageBox.NoButton,
733
+ )
734
+
735
+ length = util.polyline_length(self.measurement_w.mem_video)
736
+ try:
737
+ length = length / float(self.measurement_w.lePx.text()) * float(self.measurement_w.leRef.text())
738
+ except Exception:
739
+ QMessageBox.critical(
740
+ None,
741
+ cfg.programName,
742
+ "Check reference and pixel values! Values must be numeric.",
743
+ QMessageBox.Ok | QMessageBox.Default,
744
+ QMessageBox.NoButton,
745
+ )
746
+
747
+ append_results(
748
+ self,
749
+ (
750
+ n_player + 1,
751
+ media_file_name,
752
+ f"{self.getLaps():.03f}",
753
+ current_frame,
754
+ cfg.POLYGON_OBJECT,
755
+ cfg.NA,
756
+ cfg.NA,
757
+ round(length, 1),
758
+ round(area, 1),
759
+ cfg.NA,
760
+ str(self.measurement_w.mem_video),
761
+ ),
762
+ )
763
+
764
+ self.measurement_w.flag_saved = False
765
+ self.measurement_w.mem_points, self.measurement_w.mem_video = [], []
766
+
767
+ # polyline
768
+ elif self.measurement_w.rb_polyline.isChecked():
769
+ if event.button() == Qt.LeftButton:
770
+ draw_point(self, pixmap_x, pixmap_y, self.measurement_w.mark_color, n_player)
771
+ if len(self.measurement_w.mem_points):
772
+ draw_line(
773
+ self,
774
+ self.measurement_w.mem_points[-1][0],
775
+ self.measurement_w.mem_points[-1][1],
776
+ pixmap_x,
777
+ pixmap_y,
778
+ self.measurement_w.mark_color,
779
+ n_player,
780
+ )
781
+ self.measurement_w.mem_points.append((pixmap_x, pixmap_y))
782
+ self.measurement_w.mem_video.append((x_video, y_video))
783
+
784
+ if event.button() == Qt.RightButton:
785
+ if current_frame not in self.measurement_w.draw_mem:
786
+ self.measurement_w.draw_mem[current_frame] = []
787
+
788
+ self.measurement_w.draw_mem[current_frame].append(
789
+ {
790
+ "player": n_player,
791
+ "object_type": cfg.POLYLINE_OBJECT,
792
+ "color": self.measurement_w.mark_color,
793
+ "coordinates": self.measurement_w.mem_video,
794
+ }
795
+ )
796
+
797
+ length = util.polyline_length(self.measurement_w.mem_video)
798
+ try:
799
+ length = length / float(self.measurement_w.lePx.text()) * float(self.measurement_w.leRef.text())
800
+ except Exception:
801
+ QMessageBox.critical(
802
+ None,
803
+ cfg.programName,
804
+ "Check reference and pixel values! Values must be numeric.",
805
+ QMessageBox.Ok | QMessageBox.Default,
806
+ QMessageBox.NoButton,
807
+ )
808
+
809
+ append_results(
810
+ self,
811
+ (
812
+ n_player + 1,
813
+ media_file_name,
814
+ f"{self.getLaps():.03f}",
815
+ current_frame,
816
+ cfg.POLYLINE_OBJECT,
817
+ cfg.NA,
818
+ cfg.NA,
819
+ round(length, 1),
820
+ cfg.NA,
821
+ cfg.NA,
822
+ str(self.measurement_w.mem_video),
823
+ ),
824
+ )
825
+ self.measurement_w.mem_points, self.measurement_w.mem_video = [], []
826
+ self.measurement_w.flag_saved = False
827
+
828
+ else:
829
+ self.measurement_w.status_lb.setText("<b>Choose a measurement type!</b>")
830
+
831
+
832
+ def redraw_measurements(self):
833
+ """
834
+ redraw measurements from previous frames
835
+ """
836
+
837
+ def scale_coord(coord_list: list) -> List[int]:
838
+ """
839
+ scale coordinates from original media resolution to pixmap
840
+ """
841
+
842
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.IMAGES:
843
+ # pixmap_size = QPixmap(self.images_list[self.image_idx]).size()
844
+ original_width, original_height = self.current_image_size
845
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.MEDIA:
846
+ original_width = dw.player.width
847
+ original_height = dw.player.height
848
+
849
+ pixmap_coord_list: List[int] = []
850
+ for idx, coord in enumerate(coord_list):
851
+ if idx % 2 == 0:
852
+ coord_pixmap = round(coord / original_width * dw.frame_viewer.pixmap().width())
853
+ else:
854
+ coord_pixmap = round(coord / original_height * dw.frame_viewer.pixmap().height())
855
+ pixmap_coord_list.append(coord_pixmap)
856
+
857
+ return pixmap_coord_list
858
+
859
+ logging.debug("Redraw measurement marks")
860
+
861
+ if not (hasattr(self, "measurement_w") and self.measurement_w is not None and self.measurement_w.isVisible()):
862
+ return
863
+
864
+ if not self.measurement_w.cbPersistentMeasurements.isChecked():
865
+ self.measurement_w.draw_mem = {}
866
+ return
867
+
868
+ for idx, dw in enumerate(self.dw_player):
869
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.MEDIA:
870
+ if dw.player.estimated_frame_number is not None:
871
+ current_frame = dw.player.estimated_frame_number
872
+ else:
873
+ current_frame = cfg.NA
874
+ if self.pj[cfg.OBSERVATIONS][self.observationId][cfg.TYPE] == cfg.IMAGES:
875
+ current_frame = self.image_idx
876
+
877
+ for frame in self.measurement_w.draw_mem:
878
+ for element in self.measurement_w.draw_mem[frame]:
879
+ if frame == current_frame:
880
+ elements_color = element["color"]
881
+ else:
882
+ elements_color = cfg.PASSIVE_MEASUREMENTS_COLOR
883
+
884
+ if element["player"] == idx:
885
+ if element["object_type"] == cfg.POINT_OBJECT:
886
+ x, y = scale_coord(element["coordinates"][0])
887
+ draw_point(self, int(x), int(y), elements_color, n_player=idx)
888
+
889
+ if element["object_type"] == cfg.SEGMENT_OBJECT:
890
+ x1, y1 = scale_coord(element["coordinates"][0])
891
+ x2, y2 = scale_coord(element["coordinates"][1])
892
+ draw_line(self, x1, y1, x2, y2, elements_color, n_player=idx)
893
+ draw_point(self, x1, y1, elements_color, n_player=idx)
894
+ draw_point(self, x2, y2, elements_color, n_player=idx)
895
+
896
+ if element["object_type"] in (cfg.ANGLE_OBJECT, cfg.ORIENTED_ANGLE_OBJECT):
897
+ x1, y1 = scale_coord(element["coordinates"][0])
898
+ x2, y2 = scale_coord(element["coordinates"][1])
899
+ x3, y3 = scale_coord(element["coordinates"][2])
900
+ draw_line(self, x1, y1, x2, y2, elements_color, n_player=idx)
901
+ draw_line(self, x1, y1, x3, y3, elements_color, n_player=idx)
902
+ draw_point(self, x1, y1, elements_color, n_player=idx)
903
+ draw_point(self, x2, y2, elements_color, n_player=idx)
904
+ draw_point(self, x3, y3, elements_color, n_player=idx)
905
+
906
+ if element["object_type"] == cfg.POLYGON_OBJECT:
907
+ polygon = QPolygon()
908
+ for x, y in element["coordinates"]:
909
+ x, y = scale_coord([x, y])
910
+ polygon.append(QPoint(x, y))
911
+
912
+ pixmap_copy = self.dw_player[idx].frame_viewer.pixmap().copy()
913
+ painter = QPainter(pixmap_copy)
914
+
915
+ try:
916
+ painter.setPen(QColor(elements_color))
917
+ painter.drawPolygon(polygon)
918
+ finally:
919
+ painter.end()
920
+
921
+ self.dw_player[idx].frame_viewer.setPixmap(pixmap_copy)
922
+
923
+ dw.frame_viewer.update()
924
+
925
+ if element["object_type"] == cfg.POLYLINE_OBJECT:
926
+ for idx1, p1 in enumerate(element["coordinates"][:-1]):
927
+ x1, y1 = scale_coord(p1)
928
+ p2 = element["coordinates"][idx1 + 1]
929
+ x2, y2 = scale_coord(p2)
930
+
931
+ draw_line(self, x1, y1, x2, y2, elements_color, n_player=idx)
932
+
933
+
934
+ if __name__ == "__main__":
935
+ import sys
936
+
937
+ app = QApplication(sys.argv)
938
+ w = wgMeasurement(logging.getLogger().getEffectiveLevel())
939
+ w.show()
940
+
941
+ sys.exit(app.exec_())