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.
Files changed (59) hide show
  1. nucleuskit_toolkit/__init__.py +3 -0
  2. nucleuskit_toolkit/__main__.py +60 -0
  3. nucleuskit_toolkit/app.py +34 -0
  4. nucleuskit_toolkit/camera/__init__.py +9 -0
  5. nucleuskit_toolkit/camera/processing.py +374 -0
  6. nucleuskit_toolkit/config.py +86 -0
  7. nucleuskit_toolkit/hermes/__init__.py +0 -0
  8. nucleuskit_toolkit/hermes/constants.py +77 -0
  9. nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
  10. nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
  11. nucleuskit_toolkit/logging_utils.py +68 -0
  12. nucleuskit_toolkit/mqtt/__init__.py +5 -0
  13. nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
  14. nucleuskit_toolkit/pipeline.py +194 -0
  15. nucleuskit_toolkit/position/__init__.py +5 -0
  16. nucleuskit_toolkit/position/gps_processor.py +91 -0
  17. nucleuskit_toolkit/session_job.py +28 -0
  18. nucleuskit_toolkit/shimmer/__init__.py +5 -0
  19. nucleuskit_toolkit/shimmer/constants.py +32 -0
  20. nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
  21. nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
  22. nucleuskit_toolkit/tools/__init__.py +1 -0
  23. nucleuskit_toolkit/tools/eeg_regression.py +91 -0
  24. nucleuskit_toolkit/tools/recording_merger.py +437 -0
  25. nucleuskit_toolkit/ui/__init__.py +1 -0
  26. nucleuskit_toolkit/ui/icons.py +165 -0
  27. nucleuskit_toolkit/ui/main_window.py +465 -0
  28. nucleuskit_toolkit/ui/offline_job.py +144 -0
  29. nucleuskit_toolkit/ui/pages/__init__.py +1 -0
  30. nucleuskit_toolkit/ui/pages/about_page.py +88 -0
  31. nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
  32. nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
  33. nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
  34. nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
  35. nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
  36. nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
  37. nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
  38. nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
  39. nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
  40. nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
  41. nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
  42. nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
  43. nucleuskit_toolkit/ui/plot_style.py +155 -0
  44. nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
  45. nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
  46. nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
  47. nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
  48. nucleuskit_toolkit/ui/resources/__init__.py +1 -0
  49. nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
  50. nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
  51. nucleuskit_toolkit/ui/session_inspect.py +226 -0
  52. nucleuskit_toolkit/ui/theme.py +330 -0
  53. nucleuskit_toolkit/ui/widgets.py +190 -0
  54. nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
  55. nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
  56. nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
  57. nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
  58. nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
  59. 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")