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,754 @@
1
+ """Process Sessions: run the pipeline on a single session or on every session of a dataset folder.
2
+
3
+ Layout: one column of numbered step cards (1. input, 2. processing options ending with the Run pipeline button),
4
+ then the processing log. The selected folder is inspected in the background (read only) and Run is enabled once it
5
+ is a valid session or dataset. While running, Run becomes Cancel: the step in progress finishes, then the remaining
6
+ steps (and sessions) are skipped.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import queue
13
+ import sys
14
+ import threading
15
+ import traceback
16
+
17
+ from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot
18
+ from PySide6.QtGui import QTextCursor
19
+ from PySide6.QtWidgets import (
20
+ QApplication,
21
+ QCheckBox,
22
+ QComboBox,
23
+ QFileDialog,
24
+ QFrame,
25
+ QHBoxLayout,
26
+ QLineEdit,
27
+ QMessageBox,
28
+ QPlainTextEdit,
29
+ QPushButton,
30
+ QScrollArea,
31
+ QSizePolicy,
32
+ QVBoxLayout,
33
+ QWidget,
34
+ )
35
+ from reak_core.models import DEFAULT_MODEL, builtin_releases, resolve_key
36
+
37
+ from nucleuskit_toolkit.ui.icons import Spinner, icon
38
+ from nucleuskit_toolkit.ui.offline_job import QueueTextWriter, dataset_preflight, session_preflight
39
+ from nucleuskit_toolkit.ui.session_inspect import DatasetInfo, SessionInfo, inspect_input
40
+ from nucleuskit_toolkit.ui.theme import current_palette, repolish
41
+ from nucleuskit_toolkit.ui.widgets import (
42
+ Card,
43
+ ChoiceCard,
44
+ Divider,
45
+ IconLabel,
46
+ SectionHeader,
47
+ label,
48
+ set_role,
49
+ )
50
+
51
+ MAX_CONTENT_WIDTH = 1280
52
+ INSPECT_DELAY_MS = 300
53
+
54
+ MODE_TEXT = {
55
+ False: ("Session folder path", "Select the folder containing the session data.", "Enter session folder path…",
56
+ "Select session folder"),
57
+ True: ("Dataset folder path", "Select the folder containing the session folders.", "Enter dataset folder path…",
58
+ "Select dataset root folder"),
59
+ }
60
+
61
+
62
+ class PipelineWorker(QObject):
63
+ finished_ok = Signal()
64
+ finished_err = Signal(str)
65
+
66
+ def __init__(
67
+ self,
68
+ folder: str,
69
+ cfg: str | None,
70
+ log_queue: queue.SimpleQueue[str],
71
+ skip_video_rotation: bool = False,
72
+ apply_nlms: bool = True,
73
+ emotion_model: str | None = None,
74
+ cancel: threading.Event | None = None,
75
+ ) -> None:
76
+ super().__init__()
77
+ self._folder = folder
78
+ self._cfg = cfg
79
+ self._log_queue = log_queue
80
+ self._skip_video_rotation = skip_video_rotation
81
+ self._apply_nlms = apply_nlms
82
+ self._emotion_model = emotion_model
83
+ self._cancel = cancel
84
+
85
+ @Slot()
86
+ def run_pipeline(self) -> None:
87
+ err: str | None = None
88
+ writer = QueueTextWriter(self._log_queue)
89
+ old_out, old_err = sys.stdout, sys.stderr
90
+ try:
91
+ sys.stdout = writer
92
+ sys.stderr = writer
93
+ from nucleuskit_toolkit.logging_utils import configure_logging
94
+ from nucleuskit_toolkit.pipeline import NucleusKitProcessingToolkit
95
+ from nucleuskit_toolkit.session_job import session_job_from_folder
96
+
97
+ configure_logging()
98
+ job = session_job_from_folder(self._folder, pov_config_json=self._cfg)
99
+ pipe = NucleusKitProcessingToolkit(
100
+ None,
101
+ skip_video_rotation=self._skip_video_rotation,
102
+ apply_nlms=self._apply_nlms,
103
+ emotion_model=self._emotion_model,
104
+ cancel=self._cancel,
105
+ )
106
+ pipe.processSession(job)
107
+ except BaseException:
108
+ err = traceback.format_exc()
109
+ self._log_queue.put(err)
110
+ finally:
111
+ sys.stdout = old_out
112
+ sys.stderr = old_err
113
+ if err:
114
+ self.finished_err.emit("Run finished with errors (see log).")
115
+ else:
116
+ self.finished_ok.emit()
117
+
118
+
119
+ class BatchPipelineWorker(QObject):
120
+ """Runs the pipeline sequentially on a list of session folders; a failing session does not stop the batch."""
121
+
122
+ finished_ok = Signal()
123
+ finished_err = Signal(str)
124
+ progress = Signal(int) # emits 1 … N after each session completes
125
+ session_start = Signal(str) # emits "N/M <folder_name>" before each session
126
+
127
+ def __init__(
128
+ self,
129
+ folders: list[str],
130
+ log_queue: queue.SimpleQueue[str],
131
+ skip_video_rotation: bool = False,
132
+ apply_nlms: bool = True,
133
+ emotion_model: str | None = None,
134
+ cancel: threading.Event | None = None,
135
+ ) -> None:
136
+ super().__init__()
137
+ self._folders = folders
138
+ self._log_queue = log_queue
139
+ self._skip_video_rotation = skip_video_rotation
140
+ self._apply_nlms = apply_nlms
141
+ self._emotion_model = emotion_model
142
+ self._cancel = cancel
143
+
144
+ @Slot()
145
+ def run_pipeline(self) -> None:
146
+ has_error = False
147
+ writer = QueueTextWriter(self._log_queue)
148
+ old_out, old_err = sys.stdout, sys.stderr
149
+ try:
150
+ sys.stdout = writer
151
+ sys.stderr = writer
152
+ from nucleuskit_toolkit.logging_utils import configure_logging
153
+ from nucleuskit_toolkit.pipeline import NucleusKitProcessingToolkit
154
+ from nucleuskit_toolkit.session_job import session_job_from_folder
155
+
156
+ configure_logging()
157
+ total = len(self._folders)
158
+ for idx, folder in enumerate(self._folders, start=1):
159
+ if self._cancel is not None and self._cancel.is_set():
160
+ self._log_queue.put(f"\n--- Cancelled: {total - idx + 1} session(s) not processed ---\n")
161
+ break
162
+ name = os.path.basename(folder.rstrip("/\\"))
163
+ self.session_start.emit(f"{idx}/{total} {name}")
164
+ try:
165
+ job = session_job_from_folder(folder)
166
+ pipe = NucleusKitProcessingToolkit(
167
+ None,
168
+ skip_video_rotation=self._skip_video_rotation,
169
+ apply_nlms=self._apply_nlms,
170
+ emotion_model=self._emotion_model,
171
+ cancel=self._cancel,
172
+ )
173
+ pipe.processSession(job)
174
+ except BaseException as e:
175
+ msg = f"[BatchWorker] ERROR in session {name!r}: {e}\n{traceback.format_exc()}"
176
+ self._log_queue.put(msg)
177
+ has_error = True
178
+ self.progress.emit(idx)
179
+ except BaseException:
180
+ self._log_queue.put(traceback.format_exc())
181
+ has_error = True
182
+ finally:
183
+ sys.stdout = old_out
184
+ sys.stderr = old_err
185
+
186
+ if has_error:
187
+ self.finished_err.emit("Batch run finished with one or more errors (see log).")
188
+ else:
189
+ self.finished_ok.emit()
190
+
191
+
192
+ class _InspectWorker(QObject):
193
+ """Inspects the selected folder off the GUI thread (reads only)."""
194
+
195
+ done = Signal(int, object, object)
196
+
197
+ @Slot(int, str, bool)
198
+ def inspect(self, request: int, path: str, dataset: bool) -> None:
199
+ try:
200
+ result, error = inspect_input(path, dataset)
201
+ except Exception as exc: # noqa: BLE001 — shown inline, never fatal
202
+ result, error = None, f"Cannot read this folder: {exc}"
203
+ self.done.emit(request, result, error)
204
+
205
+
206
+ class OptionRow(QWidget):
207
+ """Check box with a title and a one-line description."""
208
+
209
+ def __init__(self, title: str, description: str, checked: bool, parent: QWidget | None = None) -> None:
210
+ super().__init__(parent)
211
+ row = QHBoxLayout(self)
212
+ row.setContentsMargins(0, 2, 0, 2)
213
+ row.setSpacing(14)
214
+ self.check = QCheckBox()
215
+ self.check.setChecked(checked)
216
+ self.check.setCursor(Qt.CursorShape.PointingHandCursor)
217
+ row.addWidget(self.check, 0, Qt.AlignmentFlag.AlignTop)
218
+ text = QVBoxLayout()
219
+ text.setSpacing(2)
220
+ self.title = label(title, "strong")
221
+ self.description = label(description, "hint", wrap=True)
222
+ text.addWidget(self.title)
223
+ text.addWidget(self.description)
224
+ row.addLayout(text, 1)
225
+
226
+ def mousePressEvent(self, event) -> None: # type: ignore[no-untyped-def]
227
+ if self.isEnabled() and event.button() == Qt.MouseButton.LeftButton:
228
+ self.check.toggle()
229
+ super().mousePressEvent(event)
230
+
231
+
232
+ class RunButton(QPushButton):
233
+ """Large primary action: icon, title and subtitle; switches between Run and Cancel."""
234
+
235
+ def __init__(self, parent: QWidget | None = None) -> None:
236
+ super().__init__(parent)
237
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
238
+ self.setMinimumHeight(60)
239
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
240
+ col = QVBoxLayout(self)
241
+ col.setContentsMargins(16, 8, 16, 8)
242
+ col.setSpacing(2)
243
+ top = QHBoxLayout()
244
+ top.setSpacing(10)
245
+ top.addStretch(1)
246
+ self._icon = IconLabel("play", 20, "#FFFFFF")
247
+ self._title = label("Run pipeline")
248
+ self._title.setStyleSheet("background: transparent; font-size: 17px; font-weight: 700;")
249
+ top.addWidget(self._icon)
250
+ top.addWidget(self._title)
251
+ top.addStretch(1)
252
+ self._subtitle = label("Start processing with the selected settings")
253
+ self._subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
254
+ self._subtitle.setStyleSheet("background: transparent; font-size: 13px;")
255
+ col.addLayout(top)
256
+ col.addWidget(self._subtitle)
257
+ for child in (self._icon, self._title, self._subtitle):
258
+ child.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
259
+ self.set_mode("run")
260
+
261
+ def set_mode(self, mode: str) -> None:
262
+ """``run``, ``cancel`` or ``cancelling``."""
263
+ p = current_palette()
264
+ if mode == "run":
265
+ variant, name, title = "run", "play", "Run pipeline"
266
+ subtitle = "Start processing with the selected settings"
267
+ elif mode == "cancel":
268
+ variant, name, title, subtitle = "cancel", "square", "Cancel", "Stop after the current step"
269
+ else:
270
+ variant, name, title, subtitle = "cancel", "square", "Cancelling…", "Finishing the current step"
271
+ self.setProperty("variant", variant)
272
+ repolish(self)
273
+ self._mode = mode
274
+ self._title.setText(title)
275
+ self._subtitle.setText(subtitle)
276
+ if mode != "run": # in run mode, the page enables the button once the input is valid
277
+ self.setEnabled(mode == "cancel")
278
+ self._icon.set_icon(name)
279
+ self._update_colors(p)
280
+
281
+ def changeEvent(self, event) -> None: # type: ignore[no-untyped-def]
282
+ super().changeEvent(event)
283
+ if hasattr(self, "_mode"):
284
+ self._update_colors(current_palette())
285
+
286
+ def refresh_theme(self) -> None:
287
+ self._update_colors(current_palette())
288
+
289
+ def _update_colors(self, p) -> None: # type: ignore[no-untyped-def]
290
+ if not self.isEnabled():
291
+ color = p.disabled_text
292
+ elif self._mode == "run":
293
+ color = "#FFFFFF"
294
+ else:
295
+ color = p.text
296
+ self._icon.set_icon(self._icon.icon_name, color)
297
+ for lbl in (self._title, self._subtitle):
298
+ base = lbl.styleSheet().split(" color:")[0]
299
+ lbl.setStyleSheet(f"{base} color: {color};")
300
+
301
+
302
+ class OfflinePage(QWidget):
303
+ processing_changed = Signal(bool)
304
+ _request_inspect = Signal(int, str, bool)
305
+
306
+ def __init__(self, parent: QWidget | None = None) -> None:
307
+ super().__init__(parent)
308
+ self._log_queue: queue.SimpleQueue[str] = queue.SimpleQueue()
309
+ self._thread: QThread | None = None
310
+ self._worker: QObject | None = None
311
+ self._cancel = threading.Event()
312
+ self._running = False
313
+ self._inspect_request = 0
314
+ self._input: SessionInfo | DatasetInfo | None = None
315
+ self._input_error: str | None = None
316
+ self._inspecting = False
317
+
318
+ self._build_inspector()
319
+
320
+ content = QWidget()
321
+ content.setMaximumWidth(MAX_CONTENT_WIDTH)
322
+ body = QVBoxLayout(content)
323
+ body.setContentsMargins(0, 0, 0, 0)
324
+ body.setSpacing(18)
325
+ body.addWidget(self._build_input_card())
326
+ body.addWidget(self._build_options_card())
327
+ body.addWidget(self._build_log_card(), 1)
328
+
329
+ for card in (self._input_card, self._options_card):
330
+ card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
331
+ self.setMinimumHeight(0)
332
+
333
+ # Scrolls when the window is too short instead of squeezing the cards.
334
+ scroll = QScrollArea()
335
+ scroll.setWidgetResizable(True)
336
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
337
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
338
+ scroll.setStyleSheet("QScrollArea, QScrollArea > QWidget > QWidget { background: transparent; }")
339
+ scroll.setWidget(content)
340
+
341
+ outer = QHBoxLayout(self)
342
+ outer.setContentsMargins(0, 0, 0, 0)
343
+ outer.addWidget(scroll, 1)
344
+
345
+ self._set_mode(dataset=False)
346
+ self._reset_options()
347
+ self._refresh_run()
348
+
349
+ self._poll_timer = QTimer(self)
350
+ self._poll_timer.timeout.connect(self._drain_log_queue)
351
+ self._poll_timer.start(120)
352
+ self.refresh_theme()
353
+
354
+ # ── Construction ──────────────────────────────────────────────────────────
355
+
356
+ def _build_inspector(self) -> None:
357
+ self._inspect_timer = QTimer(self)
358
+ self._inspect_timer.setSingleShot(True)
359
+ self._inspect_timer.setInterval(INSPECT_DELAY_MS)
360
+ self._inspect_timer.timeout.connect(self._start_inspection)
361
+ self._inspect_thread = QThread(self)
362
+ self._inspector = _InspectWorker()
363
+ self._inspector.moveToThread(self._inspect_thread)
364
+ self._request_inspect.connect(self._inspector.inspect)
365
+ self._inspector.done.connect(self._on_inspected)
366
+ self._inspect_thread.start()
367
+ app = QApplication.instance()
368
+ if app is not None:
369
+ app.aboutToQuit.connect(self._stop_inspector)
370
+
371
+ def _stop_inspector(self) -> None:
372
+ self._inspect_thread.quit()
373
+ self._inspect_thread.wait(2000)
374
+
375
+ def _build_input_card(self) -> Card:
376
+ card = Card()
377
+ card.body.addWidget(SectionHeader(
378
+ "Select input", "Choose whether to process a single session or all sessions in a dataset.", step=1))
379
+
380
+ self._mode_session = ChoiceCard("folder", "Single session", "Process one session folder")
381
+ self._mode_dataset = ChoiceCard("database", "Dataset (all sessions in folder)",
382
+ "Process all session folders in a dataset")
383
+ self._mode_session.clicked.connect(lambda: self._set_mode(dataset=False))
384
+ self._mode_dataset.clicked.connect(lambda: self._set_mode(dataset=True))
385
+ modes = QHBoxLayout()
386
+ modes.setSpacing(12)
387
+ modes.addWidget(self._mode_session)
388
+ modes.addWidget(self._mode_dataset)
389
+ card.body.addLayout(modes)
390
+
391
+ field = QVBoxLayout()
392
+ field.setSpacing(4)
393
+ self._path_label = label("", "strong")
394
+ self._path_help = label("", "hint")
395
+ field.addWidget(self._path_label)
396
+ field.addWidget(self._path_help)
397
+ field.addSpacing(4)
398
+
399
+ self._path = QLineEdit()
400
+ self._path.setClearButtonEnabled(True)
401
+ self._path_action = self._path.addAction(icon("folder", current_palette().muted),
402
+ QLineEdit.ActionPosition.LeadingPosition)
403
+ self._path.textChanged.connect(self._on_path_changed)
404
+ self._browse = QPushButton(" Browse…")
405
+ self._browse.setProperty("variant", "secondary")
406
+ self._browse.setCursor(Qt.CursorShape.PointingHandCursor)
407
+ self._browse.clicked.connect(self._browse_clicked)
408
+ path_row = QHBoxLayout()
409
+ path_row.setSpacing(10)
410
+ path_row.addWidget(self._path, 1)
411
+ path_row.addWidget(self._browse)
412
+ field.addLayout(path_row)
413
+ field.addSpacing(6)
414
+
415
+ status = QHBoxLayout()
416
+ status.setSpacing(8)
417
+ self._status_spinner = Spinner(16)
418
+ self._status_spinner.hide()
419
+ self._status_icon = IconLabel("check-circle", 16, "ok")
420
+ self._status_icon.hide()
421
+ self._status = label("", "hint", wrap=True)
422
+ status.addWidget(self._status_spinner, 0, Qt.AlignmentFlag.AlignTop)
423
+ status.addWidget(self._status_icon, 0, Qt.AlignmentFlag.AlignTop)
424
+ status.addWidget(self._status, 1)
425
+ field.addLayout(status)
426
+ card.body.addLayout(field)
427
+ self._input_card = card
428
+ return card
429
+
430
+ def _build_options_card(self) -> Card:
431
+ card = Card(spacing=12)
432
+ card.body.addWidget(SectionHeader("Processing options", "Configure how the pipeline should run.", step=2))
433
+ card.body.addWidget(Divider())
434
+ self._opt_skip_rotation = OptionRow(
435
+ "Skip video rotation", "Keep the original video orientation (do not rotate).", False)
436
+ self._opt_nlms = OptionRow(
437
+ "Apply adaptive NLMS decorrelation (EEG channels only)",
438
+ "Improve EEG signal decorrelation using adaptive NLMS. Emotions (fEMG) are not affected.", True)
439
+ self._opt_skip_rotation.check.setToolTip(
440
+ "When checked, the 180° rotation of rawData/video.mp4 is skipped entirely,\n"
441
+ "even if it has not been applied yet.")
442
+ self._opt_nlms.check.setToolTip(
443
+ "Apply the causal NLMS adaptive regression filter to the raw EEG before the cognition step.\n"
444
+ "For each channel, the other channels act as predictors to remove common-mode\n"
445
+ "cross-channel contamination. The emotion (fEMG) features never use it.")
446
+ card.body.addWidget(self._opt_skip_rotation)
447
+ card.body.addWidget(Divider())
448
+ card.body.addWidget(self._opt_nlms)
449
+ card.body.addWidget(Divider())
450
+
451
+ self._emotion_model = QComboBox()
452
+ self._emotion_model.setMinimumWidth(330)
453
+ self._emotion_model.setCursor(Qt.CursorShape.PointingHandCursor)
454
+ self._emotion_model.setToolTip(
455
+ "Emotion classifier release used for this run. Filter settings\n"
456
+ "(bandpass / notch) follow the selected model's config.json.")
457
+ self._default_model = resolve_key(DEFAULT_MODEL)
458
+ self._model_labels: dict[str, str] = {}
459
+ for key, release in builtin_releases().items():
460
+ text = f"{release.name} {release.version}".strip()
461
+ self._model_labels[key] = text
462
+ self._emotion_model.addItem(text + (" (recommended)" if key == self._default_model else ""), key)
463
+ model_row = QHBoxLayout()
464
+ model_text = QVBoxLayout()
465
+ model_text.setSpacing(2)
466
+ model_text.addWidget(label("Emotion model", "strong"))
467
+ model_text.addWidget(label("Choose the emotion recognition model to use.", "hint"))
468
+ model_row.addLayout(model_text, 1)
469
+ model_row.addWidget(self._emotion_model, 1, Qt.AlignmentFlag.AlignVCenter)
470
+ card.body.addLayout(model_row)
471
+ card.body.addSpacing(6)
472
+
473
+ self._run = RunButton()
474
+ self._run.clicked.connect(self._run_or_cancel)
475
+ card.body.addWidget(self._run)
476
+ self._run_hint = label("", "hint", wrap=True)
477
+ self._run_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
478
+ card.body.addWidget(self._run_hint)
479
+ self._options_card = card
480
+ return card
481
+
482
+ def _build_log_card(self) -> Card:
483
+ card = Card(spacing=12)
484
+ head = QHBoxLayout()
485
+ head.addWidget(SectionHeader("Processing log", "Real-time pipeline output will appear here.",
486
+ icon="file-text"), 1)
487
+ self._clear = QPushButton(" Clear")
488
+ self._clear.setProperty("variant", "secondary")
489
+ self._clear.setCursor(Qt.CursorShape.PointingHandCursor)
490
+ self._clear.clicked.connect(lambda: self._log.clear())
491
+ head.addWidget(self._clear, 0, Qt.AlignmentFlag.AlignTop)
492
+ card.body.addLayout(head)
493
+ self._log = QPlainTextEdit()
494
+ self._log.setObjectName("Log")
495
+ self._log.setReadOnly(True)
496
+ self._log.setPlaceholderText("No logs yet. Run the pipeline to see output here.")
497
+ self._log.setMinimumHeight(140)
498
+ card.body.addWidget(self._log, 1)
499
+ return card
500
+
501
+ # ── Theme ─────────────────────────────────────────────────────────────────
502
+
503
+ def refresh_theme(self) -> None:
504
+ p = current_palette()
505
+ self._path_action.setIcon(icon("folder", p.muted))
506
+ self._browse.setIcon(icon("folder", p.text, 16))
507
+ self._clear.setIcon(icon("trash", p.text, 16))
508
+ self._status_spinner.set_color(p.accent)
509
+ self._run.refresh_theme()
510
+
511
+ # ── Input ─────────────────────────────────────────────────────────────────
512
+
513
+ def _is_dataset_mode(self) -> bool:
514
+ return self._mode_dataset.is_selected()
515
+
516
+ def _set_mode(self, dataset: bool) -> None:
517
+ self._mode_session.set_selected(not dataset)
518
+ self._mode_dataset.set_selected(dataset)
519
+ title, helper, placeholder, _ = MODE_TEXT[dataset]
520
+ self._path_label.setText(title)
521
+ self._path_help.setText(helper)
522
+ self._path.setPlaceholderText(placeholder)
523
+ self._on_path_changed(self._path.text())
524
+
525
+ def _browse_clicked(self) -> None:
526
+ title = MODE_TEXT[self._is_dataset_mode()][3]
527
+ path = QFileDialog.getExistingDirectory(self, title, self._path.text().strip())
528
+ if path:
529
+ self._path.setText(os.path.normpath(path))
530
+
531
+ def _on_path_changed(self, _text: str = "") -> None:
532
+ self._inspect_request += 1
533
+ self._input = None
534
+ self._input_error = None
535
+ if self._path.text().strip():
536
+ self._inspecting = True
537
+ self._inspect_timer.start()
538
+ else:
539
+ self._inspecting = False
540
+ self._inspect_timer.stop()
541
+ self._refresh_status()
542
+ self._refresh_run()
543
+
544
+ def _start_inspection(self) -> None:
545
+ self._request_inspect.emit(self._inspect_request, self._path.text(), self._is_dataset_mode())
546
+
547
+ def _on_inspected(self, request: int, result: object, error: object) -> None:
548
+ if request != self._inspect_request:
549
+ return # the path or mode changed meanwhile
550
+ self._inspecting = False
551
+ self._input = result if isinstance(result, (SessionInfo, DatasetInfo)) else None
552
+ self._input_error = error if isinstance(error, str) else None
553
+ self._refresh_status()
554
+ self._refresh_run()
555
+
556
+ def _refresh_status(self) -> None:
557
+ self._status_spinner.stop()
558
+ self._status_icon.hide()
559
+ self._path.setProperty("invalid", False)
560
+ if self._inspecting:
561
+ self._status_spinner.start()
562
+ self._status.setText("Checking folder…")
563
+ set_role(self._status, "hint")
564
+ elif self._input_error:
565
+ self._status_icon.set_icon("alert", "error")
566
+ self._status_icon.show()
567
+ self._status.setText(self._input_error)
568
+ set_role(self._status, "error")
569
+ self._path.setProperty("invalid", True)
570
+ elif self._input is not None:
571
+ self._status_icon.set_icon("check-circle", "ok")
572
+ self._status_icon.show()
573
+ self._status.setText(self._input.summary())
574
+ set_role(self._status, "strong")
575
+ else:
576
+ self._status.setText("")
577
+ repolish(self._path)
578
+
579
+ # ── Options and Run ───────────────────────────────────────────────────────
580
+
581
+ def _reset_options(self) -> None:
582
+ self._opt_skip_rotation.check.setChecked(False)
583
+ self._opt_nlms.check.setChecked(True)
584
+ idx = self._emotion_model.findData(self._default_model)
585
+ if idx >= 0:
586
+ self._emotion_model.setCurrentIndex(idx)
587
+
588
+ def _refresh_run(self) -> None:
589
+ """Enable Run once the input is valid; otherwise say why under the button."""
590
+ if not self._running:
591
+ ready = self._input is not None
592
+ self._run.setEnabled(ready)
593
+ if ready:
594
+ self._set_run_hint("")
595
+ elif self._inspecting:
596
+ self._set_run_hint("Checking the selected folder…")
597
+ else:
598
+ self._set_run_hint("Select a valid session or dataset folder to enable Run.")
599
+
600
+ def _set_run_hint(self, text: str) -> None:
601
+ self._run_hint.setText(text)
602
+ self._run_hint.setVisible(bool(text))
603
+
604
+ def showEvent(self, event) -> None: # type: ignore[no-untyped-def]
605
+ super().showEvent(event)
606
+ if not self._running:
607
+ self._reset_options()
608
+
609
+ # ── Run and cancel ────────────────────────────────────────────────────────
610
+
611
+ def _run_mode(self) -> str:
612
+ if not self._running:
613
+ return "run"
614
+ return "cancelling" if self._cancel.is_set() else "cancel"
615
+
616
+ def can_leave(self) -> bool:
617
+ if self._running:
618
+ QMessageBox.information(self, "Processing", "Processing is running. Cancel it before leaving this page.")
619
+ return False
620
+ return True
621
+
622
+ def _set_processing(self, running: bool) -> None:
623
+ self._running = running
624
+ # Not the whole options card: it holds the Run / Cancel button.
625
+ for widget in (self._input_card, self._opt_skip_rotation, self._opt_nlms, self._emotion_model, self._clear):
626
+ widget.setEnabled(not running)
627
+ self._run.set_mode(self._run_mode())
628
+ if not running:
629
+ self._refresh_run()
630
+ else:
631
+ self._set_run_hint("")
632
+ self.processing_changed.emit(running)
633
+
634
+ def _run_or_cancel(self) -> None:
635
+ if self._running:
636
+ self._cancel.set()
637
+ self._run.set_mode("cancelling")
638
+ self._insert_log("\n--- Cancel requested: finishing the current step ---\n")
639
+ return
640
+ if self._input is None:
641
+ return
642
+ self._cancel.clear()
643
+ skip_rot = self._opt_skip_rotation.check.isChecked()
644
+ apply_nlms = self._opt_nlms.check.isChecked()
645
+ emotion_model = self._emotion_model.currentData()
646
+ if isinstance(self._input, DatasetInfo):
647
+ self._run_dataset(skip_rot, apply_nlms, emotion_model)
648
+ else:
649
+ self._run_single(skip_rot, apply_nlms, emotion_model)
650
+
651
+ def _log_options(self, skip_rot: bool, apply_nlms: bool, emotion_model: str | None) -> None:
652
+ if skip_rot:
653
+ self._insert_log(" (video rotation skipped by user request)\n")
654
+ if not apply_nlms:
655
+ self._insert_log(" (NLMS adaptive decorrelation disabled by user request)\n")
656
+ if emotion_model:
657
+ self._insert_log(f" (emotion model: {self._model_labels.get(emotion_model, emotion_model)})\n")
658
+
659
+ def _run_single(self, skip_rot: bool, apply_nlms: bool, emotion_model: str | None) -> None:
660
+ folder = self._path.text().strip()
661
+ err = session_preflight(folder)
662
+ if err:
663
+ QMessageBox.warning(self, "Session", err)
664
+ return
665
+ self._set_processing(True)
666
+ self._insert_log(f"\n--- Starting run: {folder!r} ---\n")
667
+ self._log_options(skip_rot, apply_nlms, emotion_model)
668
+ worker = PipelineWorker(
669
+ folder, None, self._log_queue,
670
+ skip_video_rotation=skip_rot,
671
+ apply_nlms=apply_nlms,
672
+ emotion_model=emotion_model,
673
+ cancel=self._cancel,
674
+ )
675
+ self._start_worker(QThread(), worker)
676
+
677
+ def _run_dataset(self, skip_rot: bool, apply_nlms: bool, emotion_model: str | None) -> None:
678
+ folder = self._path.text().strip()
679
+ sessions, err = dataset_preflight(folder)
680
+ if err:
681
+ QMessageBox.warning(self, "Dataset", err)
682
+ return
683
+ n = len(sessions)
684
+ if n > 10:
685
+ answer = QMessageBox.question(
686
+ self,
687
+ "Process entire dataset?",
688
+ f"This will run the pipeline on {n} sessions sequentially.\nThis may take a long time. Continue?",
689
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
690
+ )
691
+ if answer != QMessageBox.StandardButton.Yes:
692
+ return
693
+ self._set_processing(True)
694
+ self._insert_log(f"\n--- Starting dataset run: {folder!r} ({n} sessions) ---\n")
695
+ self._log_options(skip_rot, apply_nlms, emotion_model)
696
+ worker = BatchPipelineWorker(
697
+ sessions, self._log_queue,
698
+ skip_video_rotation=skip_rot,
699
+ apply_nlms=apply_nlms,
700
+ emotion_model=emotion_model,
701
+ cancel=self._cancel,
702
+ )
703
+ worker.session_start.connect(lambda text: self._insert_log(f"\n--- Session {text} ---\n"))
704
+ self._start_worker(QThread(), worker)
705
+
706
+ def _start_worker(self, thread: QThread, worker: QObject) -> None:
707
+ worker.moveToThread(thread)
708
+ thread.started.connect(worker.run_pipeline)
709
+ worker.finished_ok.connect(self._on_finished_ok)
710
+ worker.finished_err.connect(self._on_finished_err)
711
+ worker.finished_ok.connect(thread.quit)
712
+ worker.finished_err.connect(thread.quit)
713
+ thread.finished.connect(worker.deleteLater)
714
+ thread.finished.connect(thread.deleteLater)
715
+ thread.finished.connect(self._clear_thread_ref)
716
+ self._thread = thread
717
+ self._worker = worker
718
+ thread.start()
719
+
720
+ def _clear_thread_ref(self) -> None:
721
+ self._thread = None
722
+ self._worker = None
723
+
724
+ def _on_finished_ok(self) -> None:
725
+ cancelled = self._cancel.is_set()
726
+ self._drain_log_queue()
727
+ self._set_processing(False)
728
+ self._on_path_changed() # outputs and layout may have changed: inspect again
729
+ if cancelled:
730
+ QMessageBox.information(self, "Pipeline", "Run cancelled.")
731
+ else:
732
+ QMessageBox.information(self, "Pipeline", "Run finished.")
733
+
734
+ def _on_finished_err(self, msg: str) -> None:
735
+ self._drain_log_queue()
736
+ self._set_processing(False)
737
+ self._on_path_changed()
738
+ QMessageBox.critical(self, "Pipeline", msg)
739
+
740
+ # ── Log helpers ───────────────────────────────────────────────────────────
741
+
742
+ def _insert_log(self, text: str) -> None:
743
+ self._log.moveCursor(QTextCursor.MoveOperation.End)
744
+ self._log.insertPlainText(text)
745
+ sb = self._log.verticalScrollBar()
746
+ sb.setValue(sb.maximum())
747
+
748
+ def _drain_log_queue(self) -> None:
749
+ try:
750
+ while True:
751
+ chunk = self._log_queue.get_nowait()
752
+ self._insert_log(chunk)
753
+ except queue.Empty:
754
+ pass