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,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
|