nucleuskit-toolkit 2.2.0__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.
- nucleuskit_toolkit/__init__.py +3 -0
- nucleuskit_toolkit/__main__.py +60 -0
- nucleuskit_toolkit/app.py +34 -0
- nucleuskit_toolkit/camera/__init__.py +9 -0
- nucleuskit_toolkit/camera/processing.py +374 -0
- nucleuskit_toolkit/config.py +86 -0
- nucleuskit_toolkit/hermes/__init__.py +0 -0
- nucleuskit_toolkit/hermes/constants.py +77 -0
- nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
- nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
- nucleuskit_toolkit/logging_utils.py +68 -0
- nucleuskit_toolkit/mqtt/__init__.py +5 -0
- nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
- nucleuskit_toolkit/pipeline.py +194 -0
- nucleuskit_toolkit/position/__init__.py +5 -0
- nucleuskit_toolkit/position/gps_processor.py +91 -0
- nucleuskit_toolkit/session_job.py +28 -0
- nucleuskit_toolkit/shimmer/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/constants.py +32 -0
- nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
- nucleuskit_toolkit/tools/__init__.py +1 -0
- nucleuskit_toolkit/tools/eeg_regression.py +91 -0
- nucleuskit_toolkit/tools/recording_merger.py +437 -0
- nucleuskit_toolkit/ui/__init__.py +1 -0
- nucleuskit_toolkit/ui/icons.py +165 -0
- nucleuskit_toolkit/ui/main_window.py +465 -0
- nucleuskit_toolkit/ui/offline_job.py +144 -0
- nucleuskit_toolkit/ui/pages/__init__.py +1 -0
- nucleuskit_toolkit/ui/pages/about_page.py +88 -0
- nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
- nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
- nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
- nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
- nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
- nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
- nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
- nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
- nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
- nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
- nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
- nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
- nucleuskit_toolkit/ui/plot_style.py +155 -0
- nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
- nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
- nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
- nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
- nucleuskit_toolkit/ui/resources/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
- nucleuskit_toolkit/ui/session_inspect.py +226 -0
- nucleuskit_toolkit/ui/theme.py +330 -0
- nucleuskit_toolkit/ui/widgets.py +190 -0
- nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
- nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
- nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
- nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
- nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
- nucleuskit_toolkit-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1524 @@
|
|
|
1
|
+
"""Session playback: optional video (rawData/video.mp4) + metrics plot (results/*.csv)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from nucleuskit_toolkit.camera import ensure_video_faststart
|
|
12
|
+
|
|
13
|
+
# Prefer PySide6 before pyqtgraph loads a different Qt binding.
|
|
14
|
+
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import pyqtgraph as pg
|
|
19
|
+
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
|
20
|
+
from PySide6.QtGui import QIcon, QKeySequence, QShortcut
|
|
21
|
+
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
|
22
|
+
from PySide6.QtMultimediaWidgets import QVideoWidget
|
|
23
|
+
from PySide6.QtWidgets import (
|
|
24
|
+
QAbstractButton,
|
|
25
|
+
QAbstractItemView,
|
|
26
|
+
QButtonGroup,
|
|
27
|
+
QCheckBox,
|
|
28
|
+
QFileDialog,
|
|
29
|
+
QHBoxLayout,
|
|
30
|
+
QHeaderView,
|
|
31
|
+
QLabel,
|
|
32
|
+
QMessageBox,
|
|
33
|
+
QPushButton,
|
|
34
|
+
QRadioButton,
|
|
35
|
+
QSlider,
|
|
36
|
+
QSplitter,
|
|
37
|
+
QTableWidget,
|
|
38
|
+
QTableWidgetItem,
|
|
39
|
+
QToolButton,
|
|
40
|
+
QVBoxLayout,
|
|
41
|
+
QWidget,
|
|
42
|
+
)
|
|
43
|
+
from reak_core.models import EMOTION_COLUMNS
|
|
44
|
+
|
|
45
|
+
from nucleuskit_toolkit.ui.plot_style import (
|
|
46
|
+
EMOTION_ORDER,
|
|
47
|
+
pen,
|
|
48
|
+
plot_colors,
|
|
49
|
+
set_label,
|
|
50
|
+
style_plot,
|
|
51
|
+
style_view,
|
|
52
|
+
translucent,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_Z_NEUTRAL = -10.0
|
|
56
|
+
_Z_EMOTION_BASE = 0.0
|
|
57
|
+
_Z_ANNOTATION = 50.0
|
|
58
|
+
_Z_PLAYHEAD = 100.0
|
|
59
|
+
|
|
60
|
+
PLAYBACK_ANNOTATIONS_FILENAME = "playback_annotations.json"
|
|
61
|
+
ANNOTATIONS_SCHEMA_VERSION = 1
|
|
62
|
+
|
|
63
|
+
# Columns to display from Cognition.csv. Only Engagement is shown for now;
|
|
64
|
+
# Focus, CognitiveLoad, Frontal, and Lateralization are kept for future use.
|
|
65
|
+
COGNITION_COLUMNS: set[str] = {"Engagement"}
|
|
66
|
+
|
|
67
|
+
# Annotation graphics: accent colour, the selected one drawn thicker in the text colour.
|
|
68
|
+
def _pen_point(selected: bool = False): # type: ignore[no-untyped-def]
|
|
69
|
+
c = plot_colors()
|
|
70
|
+
return pen(c.text, 3) if selected else pen(c.accent, 2)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _pen_zone(selected: bool = False): # type: ignore[no-untyped-def]
|
|
74
|
+
c = plot_colors()
|
|
75
|
+
return pen(c.text, 2) if selected else pen(c.accent, 1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _brush_zone(selected: bool = False): # type: ignore[no-untyped-def]
|
|
79
|
+
return pg.mkBrush(translucent(plot_colors().accent, 110 if selected else 50))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _series_color(name: str, index: int) -> str:
|
|
83
|
+
"""Named emotions/metrics keep their colour; other metrics take the non-Neutral slots in column order."""
|
|
84
|
+
c = plot_colors()
|
|
85
|
+
if name in EMOTION_ORDER:
|
|
86
|
+
return c.emotion(name)
|
|
87
|
+
return c.emotions[index + 1] if index + 1 < len(c.emotions) else c.neutral
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _has_data(column: pd.Series) -> bool:
|
|
91
|
+
"""True when the column holds at least one finite, non-zero value."""
|
|
92
|
+
values = pd.to_numeric(column, errors="coerce").to_numpy(dtype=np.float64)
|
|
93
|
+
return bool(np.any(np.isfinite(values) & (values != 0.0)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _emotion_checkbox_stylesheet(hex_color: str) -> str:
|
|
97
|
+
"""QCheckBox: the square indicator uses the series color (acts as legend); the label keeps the text colour."""
|
|
98
|
+
return (
|
|
99
|
+
"QCheckBox {"
|
|
100
|
+
" spacing: 6px;"
|
|
101
|
+
" font-weight: 600;"
|
|
102
|
+
"}"
|
|
103
|
+
"QCheckBox::indicator {"
|
|
104
|
+
" width: 14px;"
|
|
105
|
+
" height: 14px;"
|
|
106
|
+
" border-radius: 3px;"
|
|
107
|
+
" border: 1px solid palette(mid);"
|
|
108
|
+
"}"
|
|
109
|
+
"QCheckBox::indicator:unchecked {"
|
|
110
|
+
" background: palette(base);"
|
|
111
|
+
"}"
|
|
112
|
+
"QCheckBox::indicator:checked {"
|
|
113
|
+
f" background: {hex_color};"
|
|
114
|
+
f" border: 1px solid {hex_color};"
|
|
115
|
+
"}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _ordered_playback_columns(plot_cols: list[str]) -> list[str]:
|
|
120
|
+
"""Neutral first (drawn behind), then EMOTION_COLUMNS order, then any extras."""
|
|
121
|
+
seen: set[str] = set()
|
|
122
|
+
out: list[str] = []
|
|
123
|
+
if "Neutral" in plot_cols:
|
|
124
|
+
out.append("Neutral")
|
|
125
|
+
seen.add("Neutral")
|
|
126
|
+
for c in EMOTION_COLUMNS:
|
|
127
|
+
if c in plot_cols and c not in seen:
|
|
128
|
+
out.append(c)
|
|
129
|
+
seen.add(c)
|
|
130
|
+
for c in plot_cols:
|
|
131
|
+
if c not in seen:
|
|
132
|
+
out.append(c)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_METRIC_CONFIGS: dict[str, dict] = {
|
|
137
|
+
"emotions": {
|
|
138
|
+
"file": "Emotions.csv",
|
|
139
|
+
"label": "Emotions",
|
|
140
|
+
"y_label": "Probability",
|
|
141
|
+
"y_range": (0.0, 1.0),
|
|
142
|
+
"filled": True,
|
|
143
|
+
},
|
|
144
|
+
"arousal": {
|
|
145
|
+
"file": "Arousal.csv",
|
|
146
|
+
"label": "Arousal",
|
|
147
|
+
"y_label": "EDA (z-score)",
|
|
148
|
+
"y_range": None,
|
|
149
|
+
"filled": False,
|
|
150
|
+
},
|
|
151
|
+
"cognition": {
|
|
152
|
+
"file": "Cognition.csv",
|
|
153
|
+
"label": "Cognition",
|
|
154
|
+
"y_label": "Index",
|
|
155
|
+
"y_range": None,
|
|
156
|
+
"filled": False,
|
|
157
|
+
},
|
|
158
|
+
"heartdynamics": {
|
|
159
|
+
"file": "HeartDynamics.csv",
|
|
160
|
+
"label": "Heart Dynamics",
|
|
161
|
+
"y_label": "HRV Metrics",
|
|
162
|
+
"y_range": None,
|
|
163
|
+
"filled": False,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_METRIC_ORDER = ["emotions", "arousal", "cognition", "heartdynamics"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _session_playback_paths(session_dir: str) -> tuple[str, str]:
|
|
171
|
+
root = Path(session_dir).expanduser().resolve()
|
|
172
|
+
video = root / "rawData" / "video.mp4"
|
|
173
|
+
emotions = root / "results" / "Emotions.csv"
|
|
174
|
+
return str(video), str(emotions)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _available_metrics(session_dir: str) -> list[str]:
|
|
178
|
+
"""Return metric keys (in display order) whose result CSV exists."""
|
|
179
|
+
root = Path(session_dir).expanduser().resolve()
|
|
180
|
+
return [
|
|
181
|
+
key for key in _METRIC_ORDER
|
|
182
|
+
if (root / "results" / _METRIC_CONFIGS[key]["file"]).is_file()
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _metric_csv_path(session_dir: str, metric_key: str) -> str:
|
|
187
|
+
root = Path(session_dir).expanduser().resolve()
|
|
188
|
+
return str(root / "results" / _METRIC_CONFIGS[metric_key]["file"])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def playback_events_dir(session_dir: str) -> Path:
|
|
192
|
+
return Path(session_dir).expanduser().resolve() / "features" / "events"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def playback_annotations_path(session_dir: str) -> Path:
|
|
196
|
+
return playback_events_dir(session_dir) / PLAYBACK_ANNOTATIONS_FILENAME
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def playback_annotations_results_path(session_dir: str) -> Path:
|
|
200
|
+
return Path(session_dir).expanduser().resolve() / "results" / PLAYBACK_ANNOTATIONS_FILENAME
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _playback_preflight(session_dir: str) -> str | None:
|
|
204
|
+
available = _available_metrics(session_dir)
|
|
205
|
+
if not available:
|
|
206
|
+
files = ", ".join(cfg["file"] for cfg in _METRIC_CONFIGS.values())
|
|
207
|
+
return f"No result files found in results/.\nExpected at least one of: {files}"
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _session_has_video(session_dir: str) -> bool:
|
|
212
|
+
video, _ = _session_playback_paths(session_dir)
|
|
213
|
+
return Path(video).is_file()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _clamp_t(t: float, t_min: float, t_max: float) -> float:
|
|
217
|
+
return max(t_min, min(t_max, float(t)))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _fmt_ms(ms: int) -> str:
|
|
221
|
+
"""Format milliseconds as M:SS (no zero-padding for minutes)."""
|
|
222
|
+
s = max(0, ms) // 1000
|
|
223
|
+
return f"{s // 60}:{s % 60:02d}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class _PointAnn:
|
|
228
|
+
ann_id: str
|
|
229
|
+
t: float
|
|
230
|
+
label: str
|
|
231
|
+
visible: bool = True
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class _ZoneAnn:
|
|
236
|
+
ann_id: str
|
|
237
|
+
t0: float
|
|
238
|
+
t1: float
|
|
239
|
+
label: str
|
|
240
|
+
visible: bool = True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class PlaybackAnnotationManager(QObject):
|
|
244
|
+
"""Point events (vertical lines) and zones (linear regions) on the plot."""
|
|
245
|
+
|
|
246
|
+
changed = Signal()
|
|
247
|
+
selection_changed = Signal(object) # emits str ann_id or None
|
|
248
|
+
|
|
249
|
+
def __init__(self, plot: pg.PlotWidget, get_time_bounds: callable) -> None:
|
|
250
|
+
super().__init__(plot)
|
|
251
|
+
self._plot = plot
|
|
252
|
+
self._get_time_bounds = get_time_bounds
|
|
253
|
+
self._points: dict[str, _PointAnn] = {}
|
|
254
|
+
self._zones: dict[str, _ZoneAnn] = {}
|
|
255
|
+
self._line_items: dict[str, pg.InfiniteLine] = {}
|
|
256
|
+
self._region_items: dict[str, pg.LinearRegionItem] = {}
|
|
257
|
+
self._selected_id: str | None = None
|
|
258
|
+
self._tool_mode = "navigate"
|
|
259
|
+
self._zone_pending_t0: float | None = None
|
|
260
|
+
|
|
261
|
+
def set_tool_mode(self, mode: str) -> None:
|
|
262
|
+
self._tool_mode = mode
|
|
263
|
+
self._zone_pending_t0 = None
|
|
264
|
+
|
|
265
|
+
def zone_pending_hint(self) -> str | None:
|
|
266
|
+
if self._tool_mode != "zone":
|
|
267
|
+
return None
|
|
268
|
+
if self._zone_pending_t0 is None:
|
|
269
|
+
return "Click start time, then end time."
|
|
270
|
+
return "Click end time."
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _set_region_pen(reg: pg.LinearRegionItem, pen: pg.QtGui.QPen) -> None:
|
|
274
|
+
"""LinearRegionItem has no setPen(); set it on each border line."""
|
|
275
|
+
for line in reg.lines:
|
|
276
|
+
line.setPen(pen)
|
|
277
|
+
|
|
278
|
+
def _configure_zone_item(self, reg: pg.LinearRegionItem) -> None:
|
|
279
|
+
reg.setZValue(_Z_ANNOTATION)
|
|
280
|
+
self._set_region_pen(reg, _pen_zone())
|
|
281
|
+
reg.setBrush(_brush_zone())
|
|
282
|
+
reg.setSpan(0.0, 1.0)
|
|
283
|
+
|
|
284
|
+
def set_visible(self, ann_id: str, visible: bool) -> None:
|
|
285
|
+
if ann_id in self._points:
|
|
286
|
+
self._points[ann_id].visible = visible
|
|
287
|
+
self._line_items[ann_id].setVisible(visible)
|
|
288
|
+
elif ann_id in self._zones:
|
|
289
|
+
self._zones[ann_id].visible = visible
|
|
290
|
+
self._region_items[ann_id].setVisible(visible)
|
|
291
|
+
|
|
292
|
+
def clear(self) -> None:
|
|
293
|
+
for it in list(self._line_items.values()):
|
|
294
|
+
self._plot.removeItem(it)
|
|
295
|
+
for it in list(self._region_items.values()):
|
|
296
|
+
self._plot.removeItem(it)
|
|
297
|
+
self._points.clear()
|
|
298
|
+
self._zones.clear()
|
|
299
|
+
self._line_items.clear()
|
|
300
|
+
self._region_items.clear()
|
|
301
|
+
self._selected_id = None
|
|
302
|
+
self._zone_pending_t0 = None
|
|
303
|
+
self.selection_changed.emit(None)
|
|
304
|
+
|
|
305
|
+
def selected_id(self) -> str | None:
|
|
306
|
+
return self._selected_id
|
|
307
|
+
|
|
308
|
+
def select(self, ann_id: str | None) -> None:
|
|
309
|
+
if self._selected_id == ann_id:
|
|
310
|
+
if ann_id is not None:
|
|
311
|
+
self._apply_selection_style()
|
|
312
|
+
return
|
|
313
|
+
self._selected_id = ann_id
|
|
314
|
+
self._apply_selection_style()
|
|
315
|
+
self.selection_changed.emit(ann_id)
|
|
316
|
+
|
|
317
|
+
def _apply_selection_style(self) -> None:
|
|
318
|
+
sid = self._selected_id
|
|
319
|
+
for aid, line in self._line_items.items():
|
|
320
|
+
line.setPen(_pen_point(aid == sid))
|
|
321
|
+
for aid, reg in self._region_items.items():
|
|
322
|
+
self._set_region_pen(reg, _pen_zone(aid == sid))
|
|
323
|
+
reg.setBrush(_brush_zone(aid == sid))
|
|
324
|
+
|
|
325
|
+
def refresh_theme(self) -> None:
|
|
326
|
+
self._apply_selection_style()
|
|
327
|
+
|
|
328
|
+
def _bounds(self) -> tuple[float, float]:
|
|
329
|
+
return self._get_time_bounds()
|
|
330
|
+
|
|
331
|
+
def handle_scene_click(self, ev) -> None:
|
|
332
|
+
if ev.button() != Qt.MouseButton.LeftButton:
|
|
333
|
+
return
|
|
334
|
+
pos = ev.scenePos()
|
|
335
|
+
if not self._plot.sceneBoundingRect().contains(pos):
|
|
336
|
+
return
|
|
337
|
+
vb = self._plot.getViewBox()
|
|
338
|
+
t = float(vb.mapSceneToView(pos).x())
|
|
339
|
+
t_min, t_max = self._bounds()
|
|
340
|
+
t = _clamp_t(t, t_min, t_max)
|
|
341
|
+
|
|
342
|
+
if self._tool_mode == "event":
|
|
343
|
+
self._add_point(t)
|
|
344
|
+
elif self._tool_mode == "zone":
|
|
345
|
+
if self._zone_pending_t0 is None:
|
|
346
|
+
self._zone_pending_t0 = t
|
|
347
|
+
else:
|
|
348
|
+
t0, t1 = self._zone_pending_t0, t
|
|
349
|
+
self._zone_pending_t0 = None
|
|
350
|
+
if abs(t1 - t0) < 1e-6:
|
|
351
|
+
return
|
|
352
|
+
if t0 > t1:
|
|
353
|
+
t0, t1 = t1, t0
|
|
354
|
+
self._add_zone(t0, t1)
|
|
355
|
+
|
|
356
|
+
def _add_point(self, t: float) -> None:
|
|
357
|
+
aid = str(uuid.uuid4())
|
|
358
|
+
n = len(self._points) + 1
|
|
359
|
+
ann = _PointAnn(aid, t, f"Event {n}")
|
|
360
|
+
self._points[aid] = ann
|
|
361
|
+
line = pg.InfiniteLine(pos=t, angle=90, movable=True, pen=_pen_point())
|
|
362
|
+
line.setZValue(_Z_ANNOTATION)
|
|
363
|
+
line.setVisible(ann.visible)
|
|
364
|
+
setattr(line, "_ann_id", aid)
|
|
365
|
+
setattr(line, "_ann_kind", "point")
|
|
366
|
+
self._line_items[aid] = line
|
|
367
|
+
self._plot.addItem(line)
|
|
368
|
+
self._wire_point_line(line, aid)
|
|
369
|
+
self.select(aid)
|
|
370
|
+
self.changed.emit()
|
|
371
|
+
|
|
372
|
+
def _wire_point_line(self, line: pg.InfiniteLine, aid: str) -> None:
|
|
373
|
+
sig = getattr(line, "sigPositionChangeFinished", None) or line.sigPositionChanged
|
|
374
|
+
|
|
375
|
+
def _on_pos(_=None) -> None:
|
|
376
|
+
t_min, t_max = self._bounds()
|
|
377
|
+
nt = _clamp_t(float(line.value()), t_min, t_max)
|
|
378
|
+
if abs(nt - float(line.value())) > 1e-12:
|
|
379
|
+
line.blockSignals(True)
|
|
380
|
+
line.setPos(nt)
|
|
381
|
+
line.blockSignals(False)
|
|
382
|
+
if aid in self._points:
|
|
383
|
+
self._points[aid].t = nt
|
|
384
|
+
self.changed.emit()
|
|
385
|
+
|
|
386
|
+
sig.connect(_on_pos)
|
|
387
|
+
|
|
388
|
+
def _add_zone(self, t0: float, t1: float) -> None:
|
|
389
|
+
aid = str(uuid.uuid4())
|
|
390
|
+
n = len(self._zones) + 1
|
|
391
|
+
ann = _ZoneAnn(aid, t0, t1, f"Zone {n}")
|
|
392
|
+
self._zones[aid] = ann
|
|
393
|
+
reg = pg.LinearRegionItem(
|
|
394
|
+
values=(t0, t1),
|
|
395
|
+
orientation="vertical",
|
|
396
|
+
movable=True,
|
|
397
|
+
bounds=self._bounds(),
|
|
398
|
+
)
|
|
399
|
+
setattr(reg, "_ann_id", aid)
|
|
400
|
+
setattr(reg, "_ann_kind", "zone")
|
|
401
|
+
self._region_items[aid] = reg
|
|
402
|
+
self._plot.addItem(reg)
|
|
403
|
+
self._configure_zone_item(reg)
|
|
404
|
+
reg.setVisible(ann.visible)
|
|
405
|
+
self._wire_zone_region(reg, aid)
|
|
406
|
+
self.select(aid)
|
|
407
|
+
self.changed.emit()
|
|
408
|
+
|
|
409
|
+
def _wire_zone_region(self, reg: pg.LinearRegionItem, aid: str) -> None:
|
|
410
|
+
sig = getattr(reg, "sigRegionChangeFinished", None) or reg.sigRegionChanged
|
|
411
|
+
|
|
412
|
+
def _on_reg() -> None:
|
|
413
|
+
t_min, t_max = self._bounds()
|
|
414
|
+
r0, r1 = reg.getRegion()
|
|
415
|
+
r0, r1 = _clamp_t(r0, t_min, t_max), _clamp_t(r1, t_min, t_max)
|
|
416
|
+
if r0 > r1:
|
|
417
|
+
r0, r1 = r1, r0
|
|
418
|
+
cur = reg.getRegion()
|
|
419
|
+
if abs(cur[0] - r0) > 1e-9 or abs(cur[1] - r1) > 1e-9:
|
|
420
|
+
reg.blockSignals(True)
|
|
421
|
+
reg.setRegion((r0, r1))
|
|
422
|
+
reg.blockSignals(False)
|
|
423
|
+
if aid in self._zones:
|
|
424
|
+
self._zones[aid].t0 = r0
|
|
425
|
+
self._zones[aid].t1 = r1
|
|
426
|
+
self.changed.emit()
|
|
427
|
+
|
|
428
|
+
sig.connect(_on_reg)
|
|
429
|
+
|
|
430
|
+
def delete_selected(self) -> None:
|
|
431
|
+
if self._selected_id is None:
|
|
432
|
+
return
|
|
433
|
+
aid = self._selected_id
|
|
434
|
+
self.select(None)
|
|
435
|
+
if aid in self._points:
|
|
436
|
+
self._plot.removeItem(self._line_items.pop(aid))
|
|
437
|
+
del self._points[aid]
|
|
438
|
+
elif aid in self._zones:
|
|
439
|
+
self._plot.removeItem(self._region_items.pop(aid))
|
|
440
|
+
del self._zones[aid]
|
|
441
|
+
self.changed.emit()
|
|
442
|
+
|
|
443
|
+
def row_snapshot(self) -> list[tuple[str, str, str, str, str, bool]]:
|
|
444
|
+
"""Rows: ann_id, type, label, start, end, visible."""
|
|
445
|
+
rows: list[tuple[str, str, str, str, str, bool]] = []
|
|
446
|
+
for aid, p in sorted(self._points.items(), key=lambda kv: kv[1].t):
|
|
447
|
+
rows.append((aid, "Event", p.label, f"{p.t:.6g}", "", p.visible))
|
|
448
|
+
for aid, z in sorted(self._zones.items(), key=lambda kv: kv[1].t0):
|
|
449
|
+
rows.append((aid, "Zone", z.label, f"{z.t0:.6g}", f"{z.t1:.6g}", z.visible))
|
|
450
|
+
return rows
|
|
451
|
+
|
|
452
|
+
def set_label(self, ann_id: str, label: str) -> None:
|
|
453
|
+
if ann_id in self._points:
|
|
454
|
+
self._points[ann_id].label = label
|
|
455
|
+
elif ann_id in self._zones:
|
|
456
|
+
self._zones[ann_id].label = label
|
|
457
|
+
|
|
458
|
+
def set_point_time(self, ann_id: str, t: float) -> None:
|
|
459
|
+
if ann_id not in self._points:
|
|
460
|
+
return
|
|
461
|
+
t_min, t_max = self._bounds()
|
|
462
|
+
t = _clamp_t(t, t_min, t_max)
|
|
463
|
+
self._points[ann_id].t = t
|
|
464
|
+
line = self._line_items[ann_id]
|
|
465
|
+
line.blockSignals(True)
|
|
466
|
+
line.setPos(t)
|
|
467
|
+
line.blockSignals(False)
|
|
468
|
+
|
|
469
|
+
def set_zone_times(self, ann_id: str, t0: float, t1: float) -> None:
|
|
470
|
+
if ann_id not in self._zones:
|
|
471
|
+
return
|
|
472
|
+
t_min, t_max = self._bounds()
|
|
473
|
+
t0, t1 = _clamp_t(t0, t_min, t_max), _clamp_t(t1, t_min, t_max)
|
|
474
|
+
if t0 > t1:
|
|
475
|
+
t0, t1 = t1, t0
|
|
476
|
+
self._zones[ann_id].t0 = t0
|
|
477
|
+
self._zones[ann_id].t1 = t1
|
|
478
|
+
reg = self._region_items[ann_id]
|
|
479
|
+
reg.blockSignals(True)
|
|
480
|
+
reg.setRegion((t0, t1))
|
|
481
|
+
reg.blockSignals(False)
|
|
482
|
+
|
|
483
|
+
def to_json_dict(self) -> dict:
|
|
484
|
+
return {
|
|
485
|
+
"version": ANNOTATIONS_SCHEMA_VERSION,
|
|
486
|
+
"points": [
|
|
487
|
+
{"id": p.ann_id, "t": p.t, "label": p.label, "visible": p.visible} for p in self._points.values()
|
|
488
|
+
],
|
|
489
|
+
"zones": [
|
|
490
|
+
{"id": z.ann_id, "t0": z.t0, "t1": z.t1, "label": z.label, "visible": z.visible}
|
|
491
|
+
for z in self._zones.values()
|
|
492
|
+
],
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
def load_from_json_dict(self, data: dict) -> str | None:
|
|
496
|
+
"""Returns error message or None."""
|
|
497
|
+
self.clear()
|
|
498
|
+
if not isinstance(data, dict):
|
|
499
|
+
self.changed.emit()
|
|
500
|
+
return "Invalid annotations file (not an object)."
|
|
501
|
+
pts = data.get("points", [])
|
|
502
|
+
zns = data.get("zones", [])
|
|
503
|
+
if not isinstance(pts, list) or not isinstance(zns, list):
|
|
504
|
+
self.changed.emit()
|
|
505
|
+
return "Invalid annotations file (points/zones)."
|
|
506
|
+
t_min, t_max = self._bounds()
|
|
507
|
+
for p in pts:
|
|
508
|
+
if not isinstance(p, dict):
|
|
509
|
+
continue
|
|
510
|
+
try:
|
|
511
|
+
aid = str(p.get("id") or uuid.uuid4())
|
|
512
|
+
t = float(p["t"])
|
|
513
|
+
label = str(p.get("label") or "Event")
|
|
514
|
+
except (KeyError, TypeError, ValueError):
|
|
515
|
+
continue
|
|
516
|
+
vis = p.get("visible") is not False
|
|
517
|
+
t = _clamp_t(t, t_min, t_max)
|
|
518
|
+
self._points[aid] = _PointAnn(aid, t, label, visible=vis)
|
|
519
|
+
line = pg.InfiniteLine(pos=t, angle=90, movable=True, pen=_pen_point())
|
|
520
|
+
line.setZValue(_Z_ANNOTATION)
|
|
521
|
+
line.setVisible(vis)
|
|
522
|
+
setattr(line, "_ann_id", aid)
|
|
523
|
+
self._line_items[aid] = line
|
|
524
|
+
self._plot.addItem(line)
|
|
525
|
+
self._wire_point_line(line, aid)
|
|
526
|
+
for z in zns:
|
|
527
|
+
if not isinstance(z, dict):
|
|
528
|
+
continue
|
|
529
|
+
try:
|
|
530
|
+
aid = str(z.get("id") or uuid.uuid4())
|
|
531
|
+
t0 = float(z["t0"])
|
|
532
|
+
t1 = float(z["t1"])
|
|
533
|
+
label = str(z.get("label") or "Zone")
|
|
534
|
+
except (KeyError, TypeError, ValueError):
|
|
535
|
+
continue
|
|
536
|
+
vis = z.get("visible") is not False
|
|
537
|
+
t0, t1 = _clamp_t(t0, t_min, t_max), _clamp_t(t1, t_min, t_max)
|
|
538
|
+
if t0 > t1:
|
|
539
|
+
t0, t1 = t1, t0
|
|
540
|
+
if abs(t1 - t0) < 1e-9:
|
|
541
|
+
continue
|
|
542
|
+
self._zones[aid] = _ZoneAnn(aid, t0, t1, label, visible=vis)
|
|
543
|
+
reg = pg.LinearRegionItem(
|
|
544
|
+
values=(t0, t1),
|
|
545
|
+
orientation="vertical",
|
|
546
|
+
movable=True,
|
|
547
|
+
bounds=(t_min, t_max),
|
|
548
|
+
)
|
|
549
|
+
setattr(reg, "_ann_id", aid)
|
|
550
|
+
self._region_items[aid] = reg
|
|
551
|
+
self._plot.addItem(reg)
|
|
552
|
+
self._configure_zone_item(reg)
|
|
553
|
+
reg.setVisible(vis)
|
|
554
|
+
self._wire_zone_region(reg, aid)
|
|
555
|
+
self._apply_selection_style()
|
|
556
|
+
self.changed.emit()
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class MetricsPlotWidget(QWidget):
|
|
561
|
+
"""Time-series metrics plot with a vertical playhead and per-series toggles."""
|
|
562
|
+
|
|
563
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
564
|
+
super().__init__(parent)
|
|
565
|
+
self._t_min = 0.0
|
|
566
|
+
self._t_max = 1.0
|
|
567
|
+
self._plot = pg.PlotWidget()
|
|
568
|
+
set_label(self._plot.getPlotItem(), "bottom", "Time", units="s")
|
|
569
|
+
set_label(self._plot.getPlotItem(), "left", "Probability")
|
|
570
|
+
self._plot.setYRange(0.0, 1.0, padding=0.02)
|
|
571
|
+
self._curves: list[pg.PlotDataItem] = []
|
|
572
|
+
self._series: list[tuple[str, QCheckBox, bool]] = [] # name, legend check box, filled
|
|
573
|
+
self._playhead = pg.InfiniteLine(pos=0.0, angle=90, movable=False, pen=pen(plot_colors().text, 2))
|
|
574
|
+
self._playhead.setZValue(_Z_PLAYHEAD)
|
|
575
|
+
|
|
576
|
+
# Raw (NaN-preserved) per-curve data stored at load time so smoothing
|
|
577
|
+
# can be applied without re-reading the CSV.
|
|
578
|
+
self._raw_data: list[tuple[np.ndarray, np.ndarray]] = []
|
|
579
|
+
self._smooth_k: int = 1 # 1 = Off (no smoothing)
|
|
580
|
+
self._dt: float = 0.5 # median timestamp step in seconds
|
|
581
|
+
|
|
582
|
+
vb = self._plot.getViewBox()
|
|
583
|
+
vb.sigRangeChanged.connect(self._on_view_range_changed)
|
|
584
|
+
self._trim_context_menu()
|
|
585
|
+
|
|
586
|
+
self._toggle_bar = QWidget()
|
|
587
|
+
self._toggle_layout = QHBoxLayout(self._toggle_bar)
|
|
588
|
+
self._toggle_layout.setContentsMargins(4, 4, 4, 0)
|
|
589
|
+
self._toggle_layout.setSpacing(10)
|
|
590
|
+
|
|
591
|
+
# Smooth bar — persists across CSV reloads (toggle bar is rebuilt each time)
|
|
592
|
+
self._smooth_bar = QWidget()
|
|
593
|
+
_sl = QHBoxLayout(self._smooth_bar)
|
|
594
|
+
_sl.setContentsMargins(4, 0, 4, 4)
|
|
595
|
+
_sl.setSpacing(6)
|
|
596
|
+
_sl.addWidget(QLabel("Smooth:"))
|
|
597
|
+
self._smooth_slider = QSlider(Qt.Orientation.Horizontal)
|
|
598
|
+
self._smooth_slider.setRange(1, 41) # 1 = Off; 41 → ~20 s at 0.5 s/sample
|
|
599
|
+
self._smooth_slider.setValue(1)
|
|
600
|
+
self._smooth_slider.setFixedWidth(150)
|
|
601
|
+
self._smooth_slider.valueChanged.connect(self._on_smooth_changed)
|
|
602
|
+
_sl.addWidget(self._smooth_slider)
|
|
603
|
+
self._smooth_value_label = QLabel("Off")
|
|
604
|
+
self._smooth_value_label.setFixedWidth(46)
|
|
605
|
+
_sl.addWidget(self._smooth_value_label)
|
|
606
|
+
_sl.addStretch(1)
|
|
607
|
+
|
|
608
|
+
layout = QVBoxLayout(self)
|
|
609
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
610
|
+
layout.addWidget(self._plot, stretch=1)
|
|
611
|
+
layout.addWidget(self._toggle_bar)
|
|
612
|
+
layout.addWidget(self._smooth_bar)
|
|
613
|
+
|
|
614
|
+
self.refresh_theme()
|
|
615
|
+
|
|
616
|
+
def plot_widget(self) -> pg.PlotWidget:
|
|
617
|
+
return self._plot
|
|
618
|
+
|
|
619
|
+
def refresh_theme(self) -> None:
|
|
620
|
+
"""Re-apply the plot colours of the current theme to the plot, the playhead and every series."""
|
|
621
|
+
style_view(self._plot)
|
|
622
|
+
style_plot(self._plot.getPlotItem())
|
|
623
|
+
self._playhead.setPen(pen(plot_colors().text, 2))
|
|
624
|
+
for i, (curve, (name, cb, filled)) in enumerate(zip(self._curves, self._series)):
|
|
625
|
+
color = _series_color(name, i)
|
|
626
|
+
curve.setPen(pen(color, 1.5))
|
|
627
|
+
if filled:
|
|
628
|
+
curve.setBrush(pg.mkBrush(color))
|
|
629
|
+
cb.setStyleSheet(_emotion_checkbox_stylesheet(color))
|
|
630
|
+
|
|
631
|
+
def _trim_context_menu(self) -> None:
|
|
632
|
+
"""Keep only 'View All' in the right-click context menu."""
|
|
633
|
+
vb = self._plot.getViewBox()
|
|
634
|
+
for action in list(vb.menu.actions()):
|
|
635
|
+
if "all" not in action.text().lower():
|
|
636
|
+
vb.menu.removeAction(action)
|
|
637
|
+
# Remove "Plot Options" (PlotItem ctrl menu)
|
|
638
|
+
self._plot.plotItem.ctrlMenu = None
|
|
639
|
+
# Remove "Export…" (GraphicsScene level)
|
|
640
|
+
self._plot.scene().contextMenu = []
|
|
641
|
+
|
|
642
|
+
def _on_view_range_changed(self, *_args) -> None:
|
|
643
|
+
"""Keep visible X inside [t_min, t_max] and width <= span."""
|
|
644
|
+
if self._t_max <= self._t_min + 1e-15:
|
|
645
|
+
return
|
|
646
|
+
vb = self._plot.getViewBox()
|
|
647
|
+
lo, hi = self._t_min, self._t_max
|
|
648
|
+
span = hi - lo
|
|
649
|
+
try:
|
|
650
|
+
x0, x1 = vb.viewRange()[0]
|
|
651
|
+
except Exception:
|
|
652
|
+
return
|
|
653
|
+
w = x1 - x0
|
|
654
|
+
changed = False
|
|
655
|
+
if w > span + 1e-9:
|
|
656
|
+
cx = (x0 + x1) * 0.5
|
|
657
|
+
x0, x1 = cx - span * 0.5, cx + span * 0.5
|
|
658
|
+
changed = True
|
|
659
|
+
if x0 < lo - 1e-12:
|
|
660
|
+
sh = lo - x0
|
|
661
|
+
x0 += sh
|
|
662
|
+
x1 += sh
|
|
663
|
+
changed = True
|
|
664
|
+
if x1 > hi + 1e-12:
|
|
665
|
+
sh = x1 - hi
|
|
666
|
+
x0 -= sh
|
|
667
|
+
x1 -= sh
|
|
668
|
+
changed = True
|
|
669
|
+
if x0 < lo:
|
|
670
|
+
x0 = lo
|
|
671
|
+
changed = True
|
|
672
|
+
if x1 > hi:
|
|
673
|
+
x1 = hi
|
|
674
|
+
changed = True
|
|
675
|
+
if x1 - x0 > span:
|
|
676
|
+
x1 = hi
|
|
677
|
+
x0 = lo
|
|
678
|
+
changed = True
|
|
679
|
+
if not changed:
|
|
680
|
+
return
|
|
681
|
+
vb.blockSignals(True)
|
|
682
|
+
try:
|
|
683
|
+
vb.setXRange(x0, x1, padding=0)
|
|
684
|
+
finally:
|
|
685
|
+
vb.blockSignals(False)
|
|
686
|
+
|
|
687
|
+
def _apply_x_view_limits(self) -> None:
|
|
688
|
+
vb = self._plot.getViewBox()
|
|
689
|
+
lo, hi = self._t_min, self._t_max
|
|
690
|
+
span = max(hi - lo, 1e-9)
|
|
691
|
+
min_w = min(max(span * 0.001, 1e-4), span * 0.5)
|
|
692
|
+
vb.setLimits(xMin=lo, xMax=hi, minXRange=min_w, maxXRange=span * 1.0001)
|
|
693
|
+
|
|
694
|
+
def _clear_toggle_widgets(self) -> None:
|
|
695
|
+
while self._toggle_layout.count():
|
|
696
|
+
item = self._toggle_layout.takeAt(0)
|
|
697
|
+
w = item.widget()
|
|
698
|
+
if w is not None:
|
|
699
|
+
w.deleteLater()
|
|
700
|
+
|
|
701
|
+
def load_csv(
|
|
702
|
+
self,
|
|
703
|
+
path: str,
|
|
704
|
+
*,
|
|
705
|
+
y_label: str = "Probability",
|
|
706
|
+
y_range: tuple[float, float] | None = (0.0, 1.0),
|
|
707
|
+
filled: bool = True,
|
|
708
|
+
) -> str | None:
|
|
709
|
+
"""Load a metrics CSV (must have a Timestamp column). Returns error or None."""
|
|
710
|
+
try:
|
|
711
|
+
df = pd.read_csv(path)
|
|
712
|
+
except Exception as e:
|
|
713
|
+
return f"Could not read {path}:\n{e}"
|
|
714
|
+
|
|
715
|
+
filename = Path(path).name
|
|
716
|
+
if "Timestamp" not in df.columns:
|
|
717
|
+
return f"{filename} must contain a Timestamp column."
|
|
718
|
+
|
|
719
|
+
for c in self._curves:
|
|
720
|
+
self._plot.removeItem(c)
|
|
721
|
+
self._curves.clear()
|
|
722
|
+
self._series.clear()
|
|
723
|
+
self._clear_toggle_widgets()
|
|
724
|
+
try:
|
|
725
|
+
self._plot.removeItem(self._playhead)
|
|
726
|
+
except Exception:
|
|
727
|
+
pass
|
|
728
|
+
leg = self._plot.plotItem.legend
|
|
729
|
+
if leg is not None:
|
|
730
|
+
try:
|
|
731
|
+
scene = leg.scene()
|
|
732
|
+
if scene is not None:
|
|
733
|
+
scene.removeItem(leg)
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
self._plot.plotItem.legend = None
|
|
737
|
+
|
|
738
|
+
ts = df["Timestamp"].to_numpy(dtype=np.float64)
|
|
739
|
+
if ts.size == 0:
|
|
740
|
+
return f"{filename} has no rows."
|
|
741
|
+
|
|
742
|
+
self._t_min = float(np.nanmin(ts))
|
|
743
|
+
self._t_max = float(np.nanmax(ts))
|
|
744
|
+
|
|
745
|
+
# Reset smoothing state for the new dataset.
|
|
746
|
+
self._raw_data.clear()
|
|
747
|
+
self._dt = float(np.median(np.diff(ts))) if len(ts) > 1 else 0.5
|
|
748
|
+
self._smooth_k = 1
|
|
749
|
+
self._smooth_slider.blockSignals(True)
|
|
750
|
+
self._smooth_slider.setValue(1)
|
|
751
|
+
self._smooth_slider.blockSignals(False)
|
|
752
|
+
self._smooth_value_label.setText("Off")
|
|
753
|
+
|
|
754
|
+
# Prefer emotion column order when emotion columns are present.
|
|
755
|
+
# For Cognition.csv apply an explicit allowlist so that sessions
|
|
756
|
+
# processed before the Engagement-only narrowing still show only
|
|
757
|
+
# the canonical metric.
|
|
758
|
+
filename = Path(path).name
|
|
759
|
+
emotion_cols = [c for c in EMOTION_COLUMNS if c in df.columns]
|
|
760
|
+
if emotion_cols:
|
|
761
|
+
plot_cols = _ordered_playback_columns(emotion_cols)
|
|
762
|
+
elif filename == "Cognition.csv":
|
|
763
|
+
plot_cols = [c for c in df.columns if c in COGNITION_COLUMNS]
|
|
764
|
+
else:
|
|
765
|
+
plot_cols = [c for c in df.columns if c != "Timestamp"]
|
|
766
|
+
# Only the series present in this session: drop columns with no non-zero value.
|
|
767
|
+
plot_cols = [c for c in plot_cols if _has_data(df[c])]
|
|
768
|
+
if not plot_cols:
|
|
769
|
+
return f"{filename} has no data to plot."
|
|
770
|
+
|
|
771
|
+
set_label(self._plot.getPlotItem(), "left", y_label)
|
|
772
|
+
|
|
773
|
+
if y_range is not None:
|
|
774
|
+
self._plot.setYRange(y_range[0], y_range[1], padding=0.02)
|
|
775
|
+
else:
|
|
776
|
+
all_vals = np.concatenate([df[c].to_numpy(dtype=np.float64) for c in plot_cols])
|
|
777
|
+
valid = all_vals[np.isfinite(all_vals)]
|
|
778
|
+
if valid.size > 0:
|
|
779
|
+
lo, hi = float(np.nanmin(valid)), float(np.nanmax(valid))
|
|
780
|
+
span = max(hi - lo, 1e-3)
|
|
781
|
+
pad = span * 0.08
|
|
782
|
+
self._plot.setYRange(lo - pad, hi + pad, padding=0)
|
|
783
|
+
|
|
784
|
+
x = ts
|
|
785
|
+
for col in plot_cols:
|
|
786
|
+
y_raw = df[col].to_numpy(dtype=np.float64) # NaN preserved for smoothing
|
|
787
|
+
self._raw_data.append((x, y_raw))
|
|
788
|
+
y = y_raw # NaN → natural line break in pyqtgraph (no data gap)
|
|
789
|
+
style_hex = _series_color(col, len(self._curves))
|
|
790
|
+
|
|
791
|
+
plot_kwargs: dict = {"pen": pen(style_hex, 1.5)}
|
|
792
|
+
if filled:
|
|
793
|
+
plot_kwargs["brush"] = pg.mkBrush(style_hex)
|
|
794
|
+
plot_kwargs["fillLevel"] = 0.0
|
|
795
|
+
|
|
796
|
+
curve = self._plot.plot(x, y, **plot_kwargs)
|
|
797
|
+
if col == "Neutral":
|
|
798
|
+
curve.setZValue(_Z_NEUTRAL)
|
|
799
|
+
else:
|
|
800
|
+
curve.setZValue(_Z_EMOTION_BASE + 0.1 * float(len(self._curves)))
|
|
801
|
+
self._curves.append(curve)
|
|
802
|
+
|
|
803
|
+
cb = QCheckBox(col)
|
|
804
|
+
cb.setChecked(True)
|
|
805
|
+
cb.setStyleSheet(_emotion_checkbox_stylesheet(style_hex))
|
|
806
|
+
cb.toggled.connect(lambda checked, c=curve: c.setVisible(checked))
|
|
807
|
+
self._toggle_layout.addWidget(cb)
|
|
808
|
+
self._series.append((col, cb, filled))
|
|
809
|
+
|
|
810
|
+
self._toggle_layout.addStretch(1)
|
|
811
|
+
|
|
812
|
+
self._apply_x_view_limits()
|
|
813
|
+
self._plot.setXRange(self._t_min, self._t_max, padding=0.02)
|
|
814
|
+
self._plot.addItem(self._playhead)
|
|
815
|
+
self.set_playhead_seconds(self._t_min)
|
|
816
|
+
return None
|
|
817
|
+
|
|
818
|
+
# ------------------------------------------------------------------
|
|
819
|
+
# Smoothing
|
|
820
|
+
# ------------------------------------------------------------------
|
|
821
|
+
|
|
822
|
+
@staticmethod
|
|
823
|
+
def _smooth_y(y_raw: np.ndarray, k: int) -> np.ndarray:
|
|
824
|
+
"""Centered rolling mean, NaN-aware. k=1 returns the array unchanged."""
|
|
825
|
+
if k <= 1:
|
|
826
|
+
return y_raw
|
|
827
|
+
return (
|
|
828
|
+
pd.Series(y_raw)
|
|
829
|
+
.rolling(k, center=True, min_periods=1)
|
|
830
|
+
.mean()
|
|
831
|
+
.to_numpy()
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
def _apply_smooth(self) -> None:
|
|
835
|
+
"""Re-render every visible curve with the current smoothing window."""
|
|
836
|
+
for curve, (x, y_raw) in zip(self._curves, self._raw_data):
|
|
837
|
+
y_s = self._smooth_y(y_raw, self._smooth_k)
|
|
838
|
+
curve.setData(x, y_s) # NaN → natural line break in pyqtgraph
|
|
839
|
+
|
|
840
|
+
def _on_smooth_changed(self, k: int) -> None:
|
|
841
|
+
self._smooth_k = k
|
|
842
|
+
if k <= 1:
|
|
843
|
+
self._smooth_value_label.setText("Off")
|
|
844
|
+
else:
|
|
845
|
+
self._smooth_value_label.setText(f"{k * self._dt:.1f} s")
|
|
846
|
+
self._apply_smooth()
|
|
847
|
+
|
|
848
|
+
# ------------------------------------------------------------------
|
|
849
|
+
# Playhead
|
|
850
|
+
# ------------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
def set_playhead_seconds(self, t: float) -> None:
|
|
853
|
+
t_clamped = max(self._t_min, min(self._t_max, float(t)))
|
|
854
|
+
self._playhead.setPos(t_clamped)
|
|
855
|
+
|
|
856
|
+
def time_range(self) -> tuple[float, float]:
|
|
857
|
+
return self._t_min, self._t_max
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
class PlaybackPage(QWidget):
|
|
861
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
862
|
+
super().__init__(parent)
|
|
863
|
+
|
|
864
|
+
self._player = QMediaPlayer(self)
|
|
865
|
+
self._audio = QAudioOutput(self)
|
|
866
|
+
self._player.setAudioOutput(self._audio)
|
|
867
|
+
|
|
868
|
+
self._video = QVideoWidget(self)
|
|
869
|
+
self._player.setVideoOutput(self._video)
|
|
870
|
+
|
|
871
|
+
self._metrics_plot = MetricsPlotWidget(self)
|
|
872
|
+
self._ann_mgr = PlaybackAnnotationManager(
|
|
873
|
+
self._metrics_plot.plot_widget(),
|
|
874
|
+
self._metrics_plot.time_range,
|
|
875
|
+
)
|
|
876
|
+
self._ann_mgr.changed.connect(self._on_annotations_changed)
|
|
877
|
+
self._ann_mgr.selection_changed.connect(self._on_ann_selection_changed)
|
|
878
|
+
|
|
879
|
+
self._plot_scene = self._metrics_plot.plot_widget().scene()
|
|
880
|
+
|
|
881
|
+
self._current_metric: str | None = None
|
|
882
|
+
self._metric_buttons: dict[str, QRadioButton] = {}
|
|
883
|
+
self._plot_scene.sigMouseClicked.connect(self._on_plot_scene_clicked)
|
|
884
|
+
|
|
885
|
+
self._session_dir: str | None = None
|
|
886
|
+
self._annotations_dirty = False
|
|
887
|
+
self._suppress_ann_changed_dirty = False
|
|
888
|
+
self._table_refreshing = False
|
|
889
|
+
|
|
890
|
+
self._tool_nav = QRadioButton("Navigate")
|
|
891
|
+
self._tool_event = QRadioButton("Event")
|
|
892
|
+
self._tool_zone = QRadioButton("Zone")
|
|
893
|
+
self._tool_nav.setChecked(True)
|
|
894
|
+
self._tool_group = QButtonGroup(self)
|
|
895
|
+
self._tool_group.addButton(self._tool_nav)
|
|
896
|
+
self._tool_group.addButton(self._tool_event)
|
|
897
|
+
self._tool_group.addButton(self._tool_zone)
|
|
898
|
+
self._tool_group.buttonClicked.connect(self._on_tool_button_clicked)
|
|
899
|
+
|
|
900
|
+
self._zone_hint = QLabel("")
|
|
901
|
+
self._zone_hint.setProperty("role", "hint")
|
|
902
|
+
|
|
903
|
+
self._save_ann = QPushButton("Save annotations")
|
|
904
|
+
self._save_ann.setEnabled(False)
|
|
905
|
+
self._save_ann.clicked.connect(self._save_annotations_file)
|
|
906
|
+
|
|
907
|
+
self._del_ann = QPushButton("Delete selected")
|
|
908
|
+
self._del_ann.setEnabled(False)
|
|
909
|
+
self._del_ann.setProperty("secondary", True)
|
|
910
|
+
self._del_ann.clicked.connect(self._delete_selected_annotation)
|
|
911
|
+
|
|
912
|
+
tool_row = QHBoxLayout()
|
|
913
|
+
tool_row.addWidget(QLabel("Tool:"))
|
|
914
|
+
tool_row.addWidget(self._tool_nav)
|
|
915
|
+
tool_row.addWidget(self._tool_event)
|
|
916
|
+
tool_row.addWidget(self._tool_zone)
|
|
917
|
+
tool_row.addWidget(self._zone_hint, stretch=1)
|
|
918
|
+
tool_row.addWidget(self._del_ann)
|
|
919
|
+
tool_row.addWidget(self._save_ann)
|
|
920
|
+
|
|
921
|
+
self._ann_table = QTableWidget(0, 6)
|
|
922
|
+
self._ann_table.setHorizontalHeaderLabels(["", "Type", "Label", "Start (s)", "End (s)", "Id"])
|
|
923
|
+
self._ann_table.hideColumn(5)
|
|
924
|
+
self._ann_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
925
|
+
self._ann_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
926
|
+
self._ann_table.itemSelectionChanged.connect(self._on_table_selection_changed)
|
|
927
|
+
self._ann_table.itemChanged.connect(self._on_table_item_changed)
|
|
928
|
+
hdr = self._ann_table.horizontalHeader()
|
|
929
|
+
hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
|
930
|
+
self._ann_table.setColumnWidth(0, 36)
|
|
931
|
+
|
|
932
|
+
ann_panel = QWidget()
|
|
933
|
+
ann_layout = QVBoxLayout(ann_panel)
|
|
934
|
+
ann_layout.setContentsMargins(0, 0, 0, 0)
|
|
935
|
+
ann_layout.addWidget(QLabel("Annotations"))
|
|
936
|
+
ann_layout.addWidget(self._ann_table, stretch=1)
|
|
937
|
+
|
|
938
|
+
self._metric_bar = QWidget()
|
|
939
|
+
self._metric_layout = QHBoxLayout(self._metric_bar)
|
|
940
|
+
self._metric_layout.setContentsMargins(0, 2, 0, 2)
|
|
941
|
+
self._metric_layout.setSpacing(8)
|
|
942
|
+
|
|
943
|
+
plot_split = QSplitter(Qt.Orientation.Horizontal)
|
|
944
|
+
plot_split.addWidget(self._metrics_plot)
|
|
945
|
+
plot_split.addWidget(ann_panel)
|
|
946
|
+
plot_split.setStretchFactor(0, 3)
|
|
947
|
+
plot_split.setStretchFactor(1, 1)
|
|
948
|
+
|
|
949
|
+
self._slider = QSlider(Qt.Orientation.Horizontal)
|
|
950
|
+
self._slider.setEnabled(False)
|
|
951
|
+
self._slider.setRange(0, 0)
|
|
952
|
+
self._slider.sliderMoved.connect(self._on_slider_moved)
|
|
953
|
+
self._slider.sliderReleased.connect(self._on_slider_released)
|
|
954
|
+
|
|
955
|
+
self._time_label = QLabel("0:00 / 0:00")
|
|
956
|
+
self._time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
957
|
+
# QSS does not support font-variant-numeric; monospace gives stable time digits without Qt warnings.
|
|
958
|
+
self._time_label.setStyleSheet(
|
|
959
|
+
'font-family: Consolas, "DejaVu Sans Mono", "Courier New", monospace; min-width: 90px;'
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
self._playback_bottom = QWidget()
|
|
963
|
+
bottom_layout = QVBoxLayout(self._playback_bottom)
|
|
964
|
+
bottom_layout.setContentsMargins(0, 0, 0, 0)
|
|
965
|
+
bottom_layout.setSpacing(6)
|
|
966
|
+
bottom_layout.addLayout(tool_row)
|
|
967
|
+
bottom_layout.addWidget(self._metric_bar)
|
|
968
|
+
bottom_layout.addWidget(plot_split, stretch=1)
|
|
969
|
+
slider_row = QHBoxLayout()
|
|
970
|
+
slider_row.addWidget(self._slider, stretch=1)
|
|
971
|
+
slider_row.addWidget(self._time_label)
|
|
972
|
+
bottom_layout.addLayout(slider_row)
|
|
973
|
+
|
|
974
|
+
self._splitter = QSplitter(Qt.Orientation.Vertical)
|
|
975
|
+
self._splitter.addWidget(self._video)
|
|
976
|
+
self._splitter.addWidget(self._playback_bottom)
|
|
977
|
+
self._splitter.setStretchFactor(0, 1)
|
|
978
|
+
self._splitter.setStretchFactor(1, 1)
|
|
979
|
+
|
|
980
|
+
self._open = QPushButton("Open session…")
|
|
981
|
+
self._open.clicked.connect(self._browse_session)
|
|
982
|
+
|
|
983
|
+
self._play = QPushButton("Play")
|
|
984
|
+
self._play.setEnabled(False)
|
|
985
|
+
self._play.clicked.connect(self._toggle_play)
|
|
986
|
+
|
|
987
|
+
self._path_label = QLabel("")
|
|
988
|
+
self._path_label.setProperty("role", "hint")
|
|
989
|
+
self._path_label.setWordWrap(True)
|
|
990
|
+
|
|
991
|
+
top = QHBoxLayout()
|
|
992
|
+
top.addStretch(1)
|
|
993
|
+
top.addWidget(self._open)
|
|
994
|
+
top.addWidget(self._play)
|
|
995
|
+
|
|
996
|
+
body = QVBoxLayout(self)
|
|
997
|
+
body.setContentsMargins(0, 0, 0, 0)
|
|
998
|
+
body.addLayout(top)
|
|
999
|
+
body.addWidget(self._path_label)
|
|
1000
|
+
body.addWidget(self._splitter, stretch=1)
|
|
1001
|
+
|
|
1002
|
+
self._player.positionChanged.connect(self._on_position_changed)
|
|
1003
|
+
self._player.durationChanged.connect(self._on_duration_changed)
|
|
1004
|
+
self._player.playbackStateChanged.connect(self._on_playback_state_changed)
|
|
1005
|
+
|
|
1006
|
+
self._has_video = False
|
|
1007
|
+
self._metrics_span_ms = 0
|
|
1008
|
+
self._metrics_position_ms = 0
|
|
1009
|
+
self._metrics_timer = QTimer(self)
|
|
1010
|
+
self._metrics_timer.setInterval(50)
|
|
1011
|
+
self._metrics_timer.timeout.connect(self._on_metrics_tick)
|
|
1012
|
+
|
|
1013
|
+
QShortcut(QKeySequence.StandardKey.Delete, self, self._delete_selected_annotation)
|
|
1014
|
+
|
|
1015
|
+
self._refresh_tool_hint()
|
|
1016
|
+
|
|
1017
|
+
def refresh_theme(self) -> None:
|
|
1018
|
+
"""Called by the main window after a theme change."""
|
|
1019
|
+
self._metrics_plot.refresh_theme()
|
|
1020
|
+
self._ann_mgr.refresh_theme()
|
|
1021
|
+
|
|
1022
|
+
def can_leave(self) -> bool:
|
|
1023
|
+
"""Called by the main window before another page is shown: offers to save edited annotations."""
|
|
1024
|
+
if not self._maybe_discard_dirty():
|
|
1025
|
+
return False
|
|
1026
|
+
self._session_dir = None
|
|
1027
|
+
self._ann_mgr.clear()
|
|
1028
|
+
self._refresh_annotation_table()
|
|
1029
|
+
self._save_ann.setEnabled(False)
|
|
1030
|
+
return True
|
|
1031
|
+
|
|
1032
|
+
def _on_tool_button_clicked(self, btn: QAbstractButton) -> None:
|
|
1033
|
+
if btn == self._tool_nav:
|
|
1034
|
+
mode = "navigate"
|
|
1035
|
+
elif btn == self._tool_event:
|
|
1036
|
+
mode = "event"
|
|
1037
|
+
else:
|
|
1038
|
+
mode = "zone"
|
|
1039
|
+
self._ann_mgr.set_tool_mode(mode)
|
|
1040
|
+
self._refresh_tool_hint()
|
|
1041
|
+
|
|
1042
|
+
def _refresh_tool_hint(self) -> None:
|
|
1043
|
+
h = self._ann_mgr.zone_pending_hint()
|
|
1044
|
+
self._zone_hint.setText(h or "")
|
|
1045
|
+
|
|
1046
|
+
def _on_plot_scene_clicked(self, ev) -> None:
|
|
1047
|
+
if self._tool_nav.isChecked():
|
|
1048
|
+
return
|
|
1049
|
+
self._ann_mgr.handle_scene_click(ev)
|
|
1050
|
+
self._refresh_tool_hint()
|
|
1051
|
+
|
|
1052
|
+
def _on_annotations_changed(self) -> None:
|
|
1053
|
+
self._refresh_annotation_table()
|
|
1054
|
+
self._refresh_tool_hint()
|
|
1055
|
+
if self._suppress_ann_changed_dirty:
|
|
1056
|
+
return
|
|
1057
|
+
self._annotations_dirty = True
|
|
1058
|
+
self._save_ann.setEnabled(True)
|
|
1059
|
+
|
|
1060
|
+
def _on_ann_selection_changed(self, ann_id: str | None) -> None:
|
|
1061
|
+
self._del_ann.setEnabled(ann_id is not None)
|
|
1062
|
+
if ann_id is None:
|
|
1063
|
+
self._ann_table.blockSignals(True)
|
|
1064
|
+
self._ann_table.clearSelection()
|
|
1065
|
+
self._ann_table.blockSignals(False)
|
|
1066
|
+
return
|
|
1067
|
+
self._select_table_row_for_id(ann_id)
|
|
1068
|
+
|
|
1069
|
+
def _select_table_row_for_id(self, ann_id: str) -> None:
|
|
1070
|
+
for r in range(self._ann_table.rowCount()):
|
|
1071
|
+
it = self._ann_table.item(r, 5)
|
|
1072
|
+
if it is not None and it.text() == ann_id:
|
|
1073
|
+
self._ann_table.blockSignals(True)
|
|
1074
|
+
self._ann_table.selectRow(r)
|
|
1075
|
+
self._ann_table.blockSignals(False)
|
|
1076
|
+
return
|
|
1077
|
+
|
|
1078
|
+
def _make_visibility_toggle(self, ann_id: str, visible: bool) -> QToolButton:
|
|
1079
|
+
btn = QToolButton()
|
|
1080
|
+
btn.setCheckable(True)
|
|
1081
|
+
btn.setChecked(visible)
|
|
1082
|
+
btn.setFixedSize(32, 24)
|
|
1083
|
+
btn.setAutoRaise(True)
|
|
1084
|
+
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
1085
|
+
ic = QIcon.fromTheme("view-visible")
|
|
1086
|
+
if not ic.isNull():
|
|
1087
|
+
btn.setIcon(ic)
|
|
1088
|
+
else:
|
|
1089
|
+
btn.setText("\U0001f441")
|
|
1090
|
+
btn.toggled.connect(lambda checked, a=ann_id: self._on_annotation_visibility_toggled(a, checked))
|
|
1091
|
+
self._update_visibility_button_tooltip(btn, visible)
|
|
1092
|
+
return btn
|
|
1093
|
+
|
|
1094
|
+
def _update_visibility_button_tooltip(self, btn: QToolButton, visible: bool) -> None:
|
|
1095
|
+
btn.setToolTip("Click to hide on plot" if visible else "Click to show on plot")
|
|
1096
|
+
|
|
1097
|
+
def _on_annotation_visibility_toggled(self, ann_id: str, visible: bool) -> None:
|
|
1098
|
+
self._ann_mgr.set_visible(ann_id, visible)
|
|
1099
|
+
snd = self.sender()
|
|
1100
|
+
if isinstance(snd, QToolButton):
|
|
1101
|
+
self._update_visibility_button_tooltip(snd, visible)
|
|
1102
|
+
self._annotations_dirty = True
|
|
1103
|
+
self._save_ann.setEnabled(True)
|
|
1104
|
+
|
|
1105
|
+
def _refresh_annotation_table(self) -> None:
|
|
1106
|
+
keep_sel = self._ann_mgr.selected_id()
|
|
1107
|
+
self._table_refreshing = True
|
|
1108
|
+
self._ann_table.blockSignals(True)
|
|
1109
|
+
try:
|
|
1110
|
+
for r in range(self._ann_table.rowCount()):
|
|
1111
|
+
w = self._ann_table.cellWidget(r, 0)
|
|
1112
|
+
if w is not None:
|
|
1113
|
+
self._ann_table.removeCellWidget(r, 0)
|
|
1114
|
+
w.deleteLater()
|
|
1115
|
+
rows = self._ann_mgr.row_snapshot()
|
|
1116
|
+
self._ann_table.setRowCount(len(rows))
|
|
1117
|
+
for i, (_aid, typ, label, start_s, end_s, vis) in enumerate(rows):
|
|
1118
|
+
self._ann_table.setCellWidget(i, 0, self._make_visibility_toggle(_aid, vis))
|
|
1119
|
+
self._ann_table.setItem(i, 1, QTableWidgetItem(typ))
|
|
1120
|
+
lab = QTableWidgetItem(label)
|
|
1121
|
+
lab.setFlags(lab.flags() | Qt.ItemFlag.ItemIsEditable)
|
|
1122
|
+
self._ann_table.setItem(i, 2, lab)
|
|
1123
|
+
st = QTableWidgetItem(start_s)
|
|
1124
|
+
en = QTableWidgetItem(end_s)
|
|
1125
|
+
if typ == "Event":
|
|
1126
|
+
st.setFlags(st.flags() | Qt.ItemFlag.ItemIsEditable)
|
|
1127
|
+
en.setFlags(en.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
1128
|
+
else:
|
|
1129
|
+
st.setFlags(st.flags() | Qt.ItemFlag.ItemIsEditable)
|
|
1130
|
+
en.setFlags(en.flags() | Qt.ItemFlag.ItemIsEditable)
|
|
1131
|
+
self._ann_table.setItem(i, 3, st)
|
|
1132
|
+
self._ann_table.setItem(i, 4, en)
|
|
1133
|
+
id_item = QTableWidgetItem(_aid)
|
|
1134
|
+
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
1135
|
+
self._ann_table.setItem(i, 5, id_item)
|
|
1136
|
+
if keep_sel is not None:
|
|
1137
|
+
for r in range(self._ann_table.rowCount()):
|
|
1138
|
+
it = self._ann_table.item(r, 5)
|
|
1139
|
+
if it is not None and it.text() == keep_sel:
|
|
1140
|
+
self._ann_table.selectRow(r)
|
|
1141
|
+
break
|
|
1142
|
+
finally:
|
|
1143
|
+
self._ann_table.blockSignals(False)
|
|
1144
|
+
self._table_refreshing = False
|
|
1145
|
+
self._del_ann.setEnabled(self._ann_mgr.selected_id() is not None)
|
|
1146
|
+
|
|
1147
|
+
def _on_table_selection_changed(self) -> None:
|
|
1148
|
+
if self._table_refreshing:
|
|
1149
|
+
return
|
|
1150
|
+
row = self._ann_table.currentRow()
|
|
1151
|
+
if row < 0:
|
|
1152
|
+
self._ann_mgr.select(None)
|
|
1153
|
+
self._del_ann.setEnabled(False)
|
|
1154
|
+
return
|
|
1155
|
+
it = self._ann_table.item(row, 5)
|
|
1156
|
+
if it is None:
|
|
1157
|
+
self._del_ann.setEnabled(False)
|
|
1158
|
+
return
|
|
1159
|
+
self._ann_mgr.select(it.text())
|
|
1160
|
+
self._del_ann.setEnabled(True)
|
|
1161
|
+
|
|
1162
|
+
def _on_table_item_changed(self, item: QTableWidgetItem) -> None:
|
|
1163
|
+
if self._table_refreshing:
|
|
1164
|
+
return
|
|
1165
|
+
row = item.row()
|
|
1166
|
+
id_item = self._ann_table.item(row, 5)
|
|
1167
|
+
if id_item is None:
|
|
1168
|
+
return
|
|
1169
|
+
aid = id_item.text()
|
|
1170
|
+
col = item.column()
|
|
1171
|
+
typ_item = self._ann_table.item(row, 1)
|
|
1172
|
+
typ = typ_item.text() if typ_item else ""
|
|
1173
|
+
if col == 2:
|
|
1174
|
+
self._ann_mgr.set_label(aid, item.text())
|
|
1175
|
+
elif col == 3:
|
|
1176
|
+
try:
|
|
1177
|
+
t = float(item.text())
|
|
1178
|
+
except ValueError:
|
|
1179
|
+
self._refresh_annotation_table()
|
|
1180
|
+
return
|
|
1181
|
+
if typ == "Event":
|
|
1182
|
+
self._ann_mgr.set_point_time(aid, t)
|
|
1183
|
+
else:
|
|
1184
|
+
en = self._ann_table.item(row, 4)
|
|
1185
|
+
try:
|
|
1186
|
+
t1 = float(en.text()) if en and en.text() else t
|
|
1187
|
+
except ValueError:
|
|
1188
|
+
self._refresh_annotation_table()
|
|
1189
|
+
return
|
|
1190
|
+
self._ann_mgr.set_zone_times(aid, t, t1)
|
|
1191
|
+
elif col == 4 and typ == "Zone":
|
|
1192
|
+
st = self._ann_table.item(row, 3)
|
|
1193
|
+
try:
|
|
1194
|
+
t0 = float(st.text()) if st else 0.0
|
|
1195
|
+
t1 = float(item.text())
|
|
1196
|
+
except ValueError:
|
|
1197
|
+
self._refresh_annotation_table()
|
|
1198
|
+
return
|
|
1199
|
+
self._ann_mgr.set_zone_times(aid, t0, t1)
|
|
1200
|
+
if col in (3, 4):
|
|
1201
|
+
self._refresh_annotation_table()
|
|
1202
|
+
self._select_table_row_for_id(aid)
|
|
1203
|
+
self._annotations_dirty = True
|
|
1204
|
+
self._save_ann.setEnabled(True)
|
|
1205
|
+
|
|
1206
|
+
def _delete_selected_annotation(self) -> None:
|
|
1207
|
+
self._ann_mgr.delete_selected()
|
|
1208
|
+
self._annotations_dirty = True
|
|
1209
|
+
self._save_ann.setEnabled(True)
|
|
1210
|
+
|
|
1211
|
+
def _save_annotations_file(self) -> None:
|
|
1212
|
+
if self._session_dir is None:
|
|
1213
|
+
return
|
|
1214
|
+
payload = json.dumps(self._ann_mgr.to_json_dict(), indent=2)
|
|
1215
|
+
primary = playback_annotations_path(self._session_dir)
|
|
1216
|
+
mirror = playback_annotations_results_path(self._session_dir)
|
|
1217
|
+
try:
|
|
1218
|
+
playback_events_dir(self._session_dir).mkdir(parents=True, exist_ok=True)
|
|
1219
|
+
primary.write_text(payload, encoding="utf-8")
|
|
1220
|
+
except OSError as e:
|
|
1221
|
+
QMessageBox.warning(self, "Playback", f"Could not save annotations:\n{e}")
|
|
1222
|
+
return
|
|
1223
|
+
try:
|
|
1224
|
+
mirror.parent.mkdir(parents=True, exist_ok=True)
|
|
1225
|
+
mirror.write_text(payload, encoding="utf-8")
|
|
1226
|
+
except OSError as e:
|
|
1227
|
+
QMessageBox.warning(
|
|
1228
|
+
self, "Playback", f"Annotations saved to features/events but could not mirror to results/:\n{e}"
|
|
1229
|
+
)
|
|
1230
|
+
self._annotations_dirty = False
|
|
1231
|
+
self._save_ann.setEnabled(False)
|
|
1232
|
+
|
|
1233
|
+
def _load_annotations_file(self) -> None:
|
|
1234
|
+
if self._session_dir is None:
|
|
1235
|
+
return
|
|
1236
|
+
path = playback_annotations_path(self._session_dir)
|
|
1237
|
+
self._suppress_ann_changed_dirty = True
|
|
1238
|
+
try:
|
|
1239
|
+
if not path.is_file():
|
|
1240
|
+
self._ann_mgr.clear()
|
|
1241
|
+
else:
|
|
1242
|
+
try:
|
|
1243
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1244
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
1245
|
+
QMessageBox.warning(self, "Playback", f"Could not read annotations file:\n{e}")
|
|
1246
|
+
self._ann_mgr.clear()
|
|
1247
|
+
else:
|
|
1248
|
+
err = self._ann_mgr.load_from_json_dict(data)
|
|
1249
|
+
if err:
|
|
1250
|
+
QMessageBox.warning(self, "Playback", err)
|
|
1251
|
+
finally:
|
|
1252
|
+
self._suppress_ann_changed_dirty = False
|
|
1253
|
+
self._refresh_annotation_table()
|
|
1254
|
+
self._annotations_dirty = False
|
|
1255
|
+
self._save_ann.setEnabled(False)
|
|
1256
|
+
|
|
1257
|
+
def _maybe_discard_dirty(self) -> bool:
|
|
1258
|
+
if not self._annotations_dirty:
|
|
1259
|
+
return True
|
|
1260
|
+
box = QMessageBox(self)
|
|
1261
|
+
box.setWindowTitle("Playback")
|
|
1262
|
+
box.setText("Save changes to playback annotations?")
|
|
1263
|
+
save_btn = box.addButton("Save", QMessageBox.ButtonRole.AcceptRole)
|
|
1264
|
+
box.addButton("Discard", QMessageBox.ButtonRole.DestructiveRole)
|
|
1265
|
+
cancel_btn = box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
|
|
1266
|
+
box.setIcon(QMessageBox.Icon.Question)
|
|
1267
|
+
box.exec()
|
|
1268
|
+
clicked = box.clickedButton()
|
|
1269
|
+
if clicked is None or clicked == cancel_btn:
|
|
1270
|
+
return False
|
|
1271
|
+
if clicked == save_btn:
|
|
1272
|
+
self._save_annotations_file()
|
|
1273
|
+
if self._annotations_dirty:
|
|
1274
|
+
return False
|
|
1275
|
+
else:
|
|
1276
|
+
self._annotations_dirty = False
|
|
1277
|
+
self._save_ann.setEnabled(False)
|
|
1278
|
+
return True
|
|
1279
|
+
|
|
1280
|
+
def _update_time_label(self, position_ms: int, duration_ms: int) -> None:
|
|
1281
|
+
self._time_label.setText(f"{_fmt_ms(position_ms)} / {_fmt_ms(duration_ms)}")
|
|
1282
|
+
|
|
1283
|
+
def _playhead_from_elapsed_ms(self, elapsed_ms: int) -> None:
|
|
1284
|
+
t_min, _t_max = self._metrics_plot.time_range()
|
|
1285
|
+
t = t_min + max(0, elapsed_ms) / 1000.0
|
|
1286
|
+
self._metrics_plot.set_playhead_seconds(t)
|
|
1287
|
+
|
|
1288
|
+
def _set_video_pane_visible(self, visible: bool) -> None:
|
|
1289
|
+
self._video.setVisible(visible)
|
|
1290
|
+
if visible:
|
|
1291
|
+
self._splitter.setSizes([400, 400])
|
|
1292
|
+
else:
|
|
1293
|
+
self._splitter.setSizes([0, 10_000])
|
|
1294
|
+
|
|
1295
|
+
def _stop_metrics_playback(self) -> None:
|
|
1296
|
+
self._metrics_timer.stop()
|
|
1297
|
+
|
|
1298
|
+
def _on_metrics_tick(self) -> None:
|
|
1299
|
+
self._metrics_position_ms = min(
|
|
1300
|
+
self._metrics_position_ms + self._metrics_timer.interval(), self._metrics_span_ms
|
|
1301
|
+
)
|
|
1302
|
+
self._slider.blockSignals(True)
|
|
1303
|
+
self._slider.setValue(self._metrics_position_ms)
|
|
1304
|
+
self._slider.blockSignals(False)
|
|
1305
|
+
self._playhead_from_elapsed_ms(self._metrics_position_ms)
|
|
1306
|
+
self._update_time_label(self._metrics_position_ms, self._metrics_span_ms)
|
|
1307
|
+
if self._metrics_position_ms >= self._metrics_span_ms:
|
|
1308
|
+
self._metrics_timer.stop()
|
|
1309
|
+
self._play.setText("Play")
|
|
1310
|
+
|
|
1311
|
+
def _browse_session(self) -> None:
|
|
1312
|
+
path = QFileDialog.getExistingDirectory(self, "Select session folder")
|
|
1313
|
+
if not path:
|
|
1314
|
+
return
|
|
1315
|
+
err = _playback_preflight(path)
|
|
1316
|
+
if err:
|
|
1317
|
+
QMessageBox.warning(self, "Playback", err)
|
|
1318
|
+
return
|
|
1319
|
+
if self._session_dir is not None and Path(path).resolve() != Path(self._session_dir).resolve():
|
|
1320
|
+
if not self._maybe_discard_dirty():
|
|
1321
|
+
return
|
|
1322
|
+
self._load_session(path)
|
|
1323
|
+
|
|
1324
|
+
def _load_session(self, session_dir: str) -> None:
|
|
1325
|
+
video_path, _ = _session_playback_paths(session_dir)
|
|
1326
|
+
|
|
1327
|
+
available = _available_metrics(session_dir)
|
|
1328
|
+
preferred = "emotions" if "emotions" in available else available[0]
|
|
1329
|
+
cfg = _METRIC_CONFIGS[preferred]
|
|
1330
|
+
csv_path = _metric_csv_path(session_dir, preferred)
|
|
1331
|
+
|
|
1332
|
+
err = self._metrics_plot.load_csv(
|
|
1333
|
+
csv_path,
|
|
1334
|
+
y_label=cfg["y_label"],
|
|
1335
|
+
y_range=cfg.get("y_range"),
|
|
1336
|
+
filled=cfg.get("filled", False),
|
|
1337
|
+
)
|
|
1338
|
+
if err:
|
|
1339
|
+
QMessageBox.warning(self, "Playback", err)
|
|
1340
|
+
return
|
|
1341
|
+
|
|
1342
|
+
self._ann_mgr.clear()
|
|
1343
|
+
self._refresh_annotation_table()
|
|
1344
|
+
self._session_dir = str(Path(session_dir).expanduser().resolve())
|
|
1345
|
+
self._current_metric = preferred
|
|
1346
|
+
self._populate_metric_selector(available, selected=preferred)
|
|
1347
|
+
self._load_annotations_file()
|
|
1348
|
+
|
|
1349
|
+
self._stop_metrics_playback()
|
|
1350
|
+
t_min, t_max = self._metrics_plot.time_range()
|
|
1351
|
+
span_s = max(0.0, t_max - t_min)
|
|
1352
|
+
self._metrics_span_ms = int(round(span_s * 1000.0))
|
|
1353
|
+
|
|
1354
|
+
self._has_video = _session_has_video(session_dir)
|
|
1355
|
+
self._path_label.setText(f"Session: {session_dir}")
|
|
1356
|
+
|
|
1357
|
+
self._player.stop()
|
|
1358
|
+
if self._has_video:
|
|
1359
|
+
self._set_video_pane_visible(True)
|
|
1360
|
+
ensure_video_faststart(session_dir)
|
|
1361
|
+
url = QUrl.fromLocalFile(str(Path(video_path).resolve()))
|
|
1362
|
+
self._player.setSource(url)
|
|
1363
|
+
else:
|
|
1364
|
+
self._player.setSource(QUrl())
|
|
1365
|
+
self._set_video_pane_visible(False)
|
|
1366
|
+
|
|
1367
|
+
self._metrics_position_ms = 0
|
|
1368
|
+
self._play.setEnabled(True)
|
|
1369
|
+
self._slider.setEnabled(True)
|
|
1370
|
+
self._slider.blockSignals(True)
|
|
1371
|
+
self._slider.setValue(0)
|
|
1372
|
+
if self._has_video:
|
|
1373
|
+
self._slider.setRange(0, 0)
|
|
1374
|
+
else:
|
|
1375
|
+
self._slider.setRange(0, max(0, self._metrics_span_ms))
|
|
1376
|
+
self._slider.blockSignals(False)
|
|
1377
|
+
|
|
1378
|
+
self._update_time_label(0, self._metrics_span_ms if not self._has_video else 0)
|
|
1379
|
+
|
|
1380
|
+
if not self._has_video:
|
|
1381
|
+
self._playhead_from_elapsed_ms(0)
|
|
1382
|
+
self._play.setText("Play")
|
|
1383
|
+
|
|
1384
|
+
def _toggle_play(self) -> None:
|
|
1385
|
+
if self._has_video:
|
|
1386
|
+
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
|
1387
|
+
self._player.pause()
|
|
1388
|
+
else:
|
|
1389
|
+
self._player.play()
|
|
1390
|
+
return
|
|
1391
|
+
if self._metrics_timer.isActive():
|
|
1392
|
+
self._metrics_timer.stop()
|
|
1393
|
+
self._play.setText("Play")
|
|
1394
|
+
else:
|
|
1395
|
+
if self._metrics_position_ms >= self._metrics_span_ms:
|
|
1396
|
+
self._metrics_position_ms = 0
|
|
1397
|
+
self._slider.blockSignals(True)
|
|
1398
|
+
self._slider.setValue(0)
|
|
1399
|
+
self._slider.blockSignals(False)
|
|
1400
|
+
self._playhead_from_elapsed_ms(0)
|
|
1401
|
+
self._metrics_timer.start()
|
|
1402
|
+
self._play.setText("Pause")
|
|
1403
|
+
|
|
1404
|
+
def _on_playback_state_changed(self) -> None:
|
|
1405
|
+
if not self._has_video:
|
|
1406
|
+
return
|
|
1407
|
+
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
|
1408
|
+
self._play.setText("Pause")
|
|
1409
|
+
else:
|
|
1410
|
+
self._play.setText("Play")
|
|
1411
|
+
|
|
1412
|
+
def _on_duration_changed(self, duration_ms: int) -> None:
|
|
1413
|
+
if not self._has_video:
|
|
1414
|
+
return
|
|
1415
|
+
self._slider.blockSignals(True)
|
|
1416
|
+
self._slider.setRange(0, max(0, duration_ms))
|
|
1417
|
+
self._slider.blockSignals(False)
|
|
1418
|
+
self._update_time_label(self._player.position(), duration_ms)
|
|
1419
|
+
|
|
1420
|
+
def _on_position_changed(self, position_ms: int) -> None:
|
|
1421
|
+
if not self._has_video:
|
|
1422
|
+
return
|
|
1423
|
+
self._slider.blockSignals(True)
|
|
1424
|
+
self._slider.setValue(position_ms)
|
|
1425
|
+
self._slider.blockSignals(False)
|
|
1426
|
+
self._playhead_from_elapsed_ms(position_ms)
|
|
1427
|
+
self._update_time_label(position_ms, self._player.duration())
|
|
1428
|
+
|
|
1429
|
+
def _on_slider_moved(self, position_ms: int) -> None:
|
|
1430
|
+
if self._has_video:
|
|
1431
|
+
self._player.setPosition(position_ms)
|
|
1432
|
+
duration_ms = self._player.duration()
|
|
1433
|
+
else:
|
|
1434
|
+
if self._metrics_timer.isActive():
|
|
1435
|
+
self._metrics_timer.stop()
|
|
1436
|
+
self._play.setText("Play")
|
|
1437
|
+
self._metrics_position_ms = position_ms
|
|
1438
|
+
duration_ms = self._metrics_span_ms
|
|
1439
|
+
self._playhead_from_elapsed_ms(position_ms)
|
|
1440
|
+
self._update_time_label(position_ms, duration_ms)
|
|
1441
|
+
|
|
1442
|
+
def _on_slider_released(self) -> None:
|
|
1443
|
+
if not self._slider.isEnabled():
|
|
1444
|
+
return
|
|
1445
|
+
ms = self._slider.value()
|
|
1446
|
+
if self._has_video:
|
|
1447
|
+
self._player.setPosition(ms)
|
|
1448
|
+
else:
|
|
1449
|
+
self._metrics_position_ms = ms
|
|
1450
|
+
self._playhead_from_elapsed_ms(ms)
|
|
1451
|
+
|
|
1452
|
+
# ------------------------------------------------------------------
|
|
1453
|
+
# Metric selector
|
|
1454
|
+
# ------------------------------------------------------------------
|
|
1455
|
+
|
|
1456
|
+
def _populate_metric_selector(self, available: list[str], selected: str | None = None) -> None:
|
|
1457
|
+
"""Rebuild the metric selector bar showing only available result files."""
|
|
1458
|
+
while self._metric_layout.count():
|
|
1459
|
+
item = self._metric_layout.takeAt(0)
|
|
1460
|
+
w = item.widget()
|
|
1461
|
+
if w is not None:
|
|
1462
|
+
w.deleteLater()
|
|
1463
|
+
self._metric_buttons.clear()
|
|
1464
|
+
|
|
1465
|
+
if not available:
|
|
1466
|
+
return
|
|
1467
|
+
|
|
1468
|
+
lbl = QLabel("Metric:")
|
|
1469
|
+
self._metric_layout.addWidget(lbl)
|
|
1470
|
+
|
|
1471
|
+
for key in _METRIC_ORDER:
|
|
1472
|
+
if key not in available:
|
|
1473
|
+
continue
|
|
1474
|
+
cfg = _METRIC_CONFIGS[key]
|
|
1475
|
+
btn = QRadioButton(cfg["label"])
|
|
1476
|
+
btn.setChecked(key == selected)
|
|
1477
|
+
btn.toggled.connect(
|
|
1478
|
+
lambda checked, k=key: self._on_metric_button_toggled(k, checked)
|
|
1479
|
+
)
|
|
1480
|
+
self._metric_buttons[key] = btn
|
|
1481
|
+
self._metric_layout.addWidget(btn)
|
|
1482
|
+
|
|
1483
|
+
self._metric_layout.addStretch(1)
|
|
1484
|
+
|
|
1485
|
+
def _on_metric_button_toggled(self, key: str, checked: bool) -> None:
|
|
1486
|
+
if not checked or key == self._current_metric:
|
|
1487
|
+
return
|
|
1488
|
+
self._switch_metric(key)
|
|
1489
|
+
|
|
1490
|
+
def _switch_metric(self, key: str) -> None:
|
|
1491
|
+
"""Load a different metric CSV into the plot without reloading the session."""
|
|
1492
|
+
if self._session_dir is None:
|
|
1493
|
+
return
|
|
1494
|
+
cfg = _METRIC_CONFIGS[key]
|
|
1495
|
+
path = _metric_csv_path(self._session_dir, key)
|
|
1496
|
+
err = self._metrics_plot.load_csv(
|
|
1497
|
+
path,
|
|
1498
|
+
y_label=cfg["y_label"],
|
|
1499
|
+
y_range=cfg.get("y_range"),
|
|
1500
|
+
filled=cfg.get("filled", False),
|
|
1501
|
+
)
|
|
1502
|
+
if err:
|
|
1503
|
+
QMessageBox.warning(self, "Playback", err)
|
|
1504
|
+
# Restore the previous button selection
|
|
1505
|
+
prev = self._current_metric
|
|
1506
|
+
if prev and prev in self._metric_buttons:
|
|
1507
|
+
self._metric_buttons[prev].blockSignals(True)
|
|
1508
|
+
self._metric_buttons[prev].setChecked(True)
|
|
1509
|
+
self._metric_buttons[prev].blockSignals(False)
|
|
1510
|
+
return
|
|
1511
|
+
self._current_metric = key
|
|
1512
|
+
|
|
1513
|
+
if not self._has_video:
|
|
1514
|
+
t_min, t_max = self._metrics_plot.time_range()
|
|
1515
|
+
span_s = max(0.0, t_max - t_min)
|
|
1516
|
+
self._metrics_span_ms = int(round(span_s * 1000.0))
|
|
1517
|
+
self._stop_metrics_playback()
|
|
1518
|
+
self._metrics_position_ms = 0
|
|
1519
|
+
self._slider.blockSignals(True)
|
|
1520
|
+
self._slider.setValue(0)
|
|
1521
|
+
self._slider.setRange(0, max(0, self._metrics_span_ms))
|
|
1522
|
+
self._slider.blockSignals(False)
|
|
1523
|
+
self._playhead_from_elapsed_ms(0)
|
|
1524
|
+
self._play.setText("Play")
|