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,465 @@
|
|
|
1
|
+
"""Main window: sidebar navigation, a shared page header and lazily created pages.
|
|
2
|
+
|
|
3
|
+
Pages are created **on first navigation** so that heavy third-party libraries (scipy, pandas, bleak, re-ak-core's
|
|
4
|
+
processing modules) are not imported at startup. While a page loads for the first time a loading screen with a
|
|
5
|
+
spinner is shown, and its imports run on a background thread so the window stays responsive. After the window
|
|
6
|
+
appears, the same libraries are warmed up in the background, so most pages open instantly.
|
|
7
|
+
|
|
8
|
+
A page can refuse to be left (a device is connected, a run is in progress, unsaved edits): the window calls its
|
|
9
|
+
``can_leave()`` before switching, when the page defines one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Callable, cast
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QObject, Qt, QThread, QUrl, Signal
|
|
19
|
+
from PySide6.QtGui import QDesktopServices
|
|
20
|
+
from PySide6.QtWidgets import (
|
|
21
|
+
QApplication,
|
|
22
|
+
QButtonGroup,
|
|
23
|
+
QFrame,
|
|
24
|
+
QHBoxLayout,
|
|
25
|
+
QLabel,
|
|
26
|
+
QMainWindow,
|
|
27
|
+
QPushButton,
|
|
28
|
+
QStackedWidget,
|
|
29
|
+
QVBoxLayout,
|
|
30
|
+
QWidget,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from nucleuskit_toolkit.ui.icons import Spinner, icon
|
|
34
|
+
from nucleuskit_toolkit.ui.pages.settings_page import load_theme_setting
|
|
35
|
+
from nucleuskit_toolkit.ui.theme import ThemeMode, apply_theme, current_palette
|
|
36
|
+
from nucleuskit_toolkit.ui.widgets import IconLabel, label, refresh_icons
|
|
37
|
+
|
|
38
|
+
REPO_URL = "https://github.com/fs-re-ak/NucleusKit-ProcessingPipeline"
|
|
39
|
+
DOCS_URL = f"{REPO_URL}/tree/main/doc"
|
|
40
|
+
README_URL = f"{REPO_URL}#"
|
|
41
|
+
|
|
42
|
+
# Libraries imported by the pages; loaded in the background after startup and before a page is first built.
|
|
43
|
+
WARMUP_MODULES = (
|
|
44
|
+
"numpy",
|
|
45
|
+
"pandas",
|
|
46
|
+
"scipy.signal",
|
|
47
|
+
"reak_core.models",
|
|
48
|
+
"reak_core.eeg",
|
|
49
|
+
"reak_core.pipeline",
|
|
50
|
+
"bleak",
|
|
51
|
+
"matplotlib",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PageSpec:
|
|
57
|
+
key: str
|
|
58
|
+
nav_label: str
|
|
59
|
+
icon: str
|
|
60
|
+
title: str
|
|
61
|
+
subtitle: str
|
|
62
|
+
help: str
|
|
63
|
+
module: str
|
|
64
|
+
factory: str
|
|
65
|
+
nav: str = "" # sidebar item highlighted for this page (defaults to key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
PAGES: dict[str, PageSpec] = {
|
|
69
|
+
spec.key: spec
|
|
70
|
+
for spec in (
|
|
71
|
+
PageSpec(
|
|
72
|
+
"process", "Process", "play", "Process Sessions",
|
|
73
|
+
"Run the Nucleus-Kit pipeline on a single session or an entire dataset.",
|
|
74
|
+
"run", "nucleuskit_toolkit.ui.pages.offline_page", "OfflinePage",
|
|
75
|
+
),
|
|
76
|
+
PageSpec(
|
|
77
|
+
"realtime", "Real-time viewer", "radio", "Real-time Viewer",
|
|
78
|
+
"Stream, view and record a Hermes headset or a Shimmer wristband live.",
|
|
79
|
+
"graphical-interface", "nucleuskit_toolkit.ui.pages.realtime_viewer_page", "RealtimeViewerPage",
|
|
80
|
+
),
|
|
81
|
+
PageSpec(
|
|
82
|
+
"playback", "Playback", "film", "Playback",
|
|
83
|
+
"Review a processed session with its video, metrics and annotations.",
|
|
84
|
+
"graphical-interface", "nucleuskit_toolkit.ui.pages.playback_page", "PlaybackPage",
|
|
85
|
+
),
|
|
86
|
+
PageSpec(
|
|
87
|
+
"tools", "Tools", "wrench", "Tools",
|
|
88
|
+
"Repair and adjust the signals of processed sessions.",
|
|
89
|
+
"tools-submenu", "nucleuskit_toolkit.ui.pages.tools_menu_page", "ToolsMenuPage",
|
|
90
|
+
),
|
|
91
|
+
PageSpec(
|
|
92
|
+
"mqtt", "MQTT Controller", "network", "MQTT Controller",
|
|
93
|
+
"Control Vizia Mobile devices over MQTT: status, commands and tags.",
|
|
94
|
+
"graphical-interface", "nucleuskit_toolkit.ui.pages.mqtt_controller_page", "MqttControllerPage",
|
|
95
|
+
),
|
|
96
|
+
PageSpec(
|
|
97
|
+
"settings", "Settings", "settings", "Settings",
|
|
98
|
+
"Appearance of the application.",
|
|
99
|
+
"graphical-interface", "nucleuskit_toolkit.ui.pages.settings_page", "SettingsPage",
|
|
100
|
+
),
|
|
101
|
+
PageSpec(
|
|
102
|
+
"about", "About", "info", "About",
|
|
103
|
+
"Version, processing core, emotion models and license.",
|
|
104
|
+
"license", "nucleuskit_toolkit.ui.pages.about_page", "AboutPage",
|
|
105
|
+
),
|
|
106
|
+
PageSpec(
|
|
107
|
+
"channel_fixer", "", "wrench", "Channel Fixer",
|
|
108
|
+
"Repair a single bad RMS channel of a processed session with the bundled channel-fix model.",
|
|
109
|
+
"tools-submenu", "nucleuskit_toolkit.ui.pages.channel_fixer_page", "ChannelFixerPage", nav="tools",
|
|
110
|
+
),
|
|
111
|
+
PageSpec(
|
|
112
|
+
"channel_gain", "", "wrench", "Channel Gain Adjustment",
|
|
113
|
+
"Adjust per-channel RMS gain and zero offset against a reference distribution.",
|
|
114
|
+
"tools-submenu", "nucleuskit_toolkit.ui.pages.channel_gain_page", "ChannelGainPage", nav="tools",
|
|
115
|
+
),
|
|
116
|
+
PageSpec(
|
|
117
|
+
"revert_original", "", "wrench", "Revert to Original",
|
|
118
|
+
"Restore the working RMS features from the frozen baseline in features/emotions/original/.",
|
|
119
|
+
"tools-submenu", "nucleuskit_toolkit.ui.pages.revert_original_page", "RevertOriginalPage", nav="tools",
|
|
120
|
+
),
|
|
121
|
+
PageSpec(
|
|
122
|
+
"ppg_fixer", "", "wrench", "PPG Fixer",
|
|
123
|
+
"Re-run PPG artifact rejection and heart dynamics on a processed session.",
|
|
124
|
+
"tools-submenu", "nucleuskit_toolkit.ui.pages.ppg_fixer_page", "PpgFixerPage", nav="tools",
|
|
125
|
+
),
|
|
126
|
+
PageSpec(
|
|
127
|
+
"eeg_regression", "", "wrench", "EEG Regression Denoising",
|
|
128
|
+
"Remove shared muscle noise from the EEG by regression, then recompute cognition.",
|
|
129
|
+
"tools-submenu", "nucleuskit_toolkit.ui.pages.eeg_regression_page", "EegRegressionPage", nav="tools",
|
|
130
|
+
),
|
|
131
|
+
PageSpec(
|
|
132
|
+
"recording_merge", "", "wrench", "Recording Merger",
|
|
133
|
+
"Join up to 5 recordings into one session, padding the gaps.",
|
|
134
|
+
"tools-submenu", "nucleuskit_toolkit.ui.pages.recording_merge_page", "RecordingMergePage", nav="tools",
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
MAIN_NAV = ("process", "realtime", "playback", "tools", "mqtt")
|
|
140
|
+
BOTTOM_NAV = ("settings", "docs", "about")
|
|
141
|
+
TOOL_SIGNALS = {
|
|
142
|
+
"open_channel_fixer": "channel_fixer",
|
|
143
|
+
"open_channel_gain": "channel_gain",
|
|
144
|
+
"open_revert_original": "revert_original",
|
|
145
|
+
"open_ppg_fixer": "ppg_fixer",
|
|
146
|
+
"open_eeg_regression": "eeg_regression",
|
|
147
|
+
"open_recording_merge": "recording_merge",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _ImportWorker(QObject):
|
|
152
|
+
"""Imports modules on a background thread (no Qt widgets are created there)."""
|
|
153
|
+
|
|
154
|
+
done = Signal()
|
|
155
|
+
|
|
156
|
+
def __init__(self, modules: tuple[str, ...]) -> None:
|
|
157
|
+
super().__init__()
|
|
158
|
+
self._modules = modules
|
|
159
|
+
|
|
160
|
+
def run(self) -> None:
|
|
161
|
+
for name in self._modules:
|
|
162
|
+
try:
|
|
163
|
+
importlib.import_module(name)
|
|
164
|
+
except Exception: # noqa: BLE001 — a missing optional library is reported by the page itself
|
|
165
|
+
pass
|
|
166
|
+
self.done.emit()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PageHeader(QWidget):
|
|
170
|
+
"""Page title, one-line subtitle and a discreet Help link on the right."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
173
|
+
super().__init__(parent)
|
|
174
|
+
self._help_anchor = ""
|
|
175
|
+
row = QHBoxLayout(self)
|
|
176
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
177
|
+
text = QVBoxLayout()
|
|
178
|
+
text.setSpacing(4)
|
|
179
|
+
self.title = label("", "title")
|
|
180
|
+
self.subtitle = label("", "subtitle", wrap=True)
|
|
181
|
+
text.addWidget(self.title)
|
|
182
|
+
text.addWidget(self.subtitle)
|
|
183
|
+
row.addLayout(text, 1)
|
|
184
|
+
self.help = QPushButton(" Help")
|
|
185
|
+
self.help.setProperty("variant", "link")
|
|
186
|
+
self.help.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
187
|
+
self.help.clicked.connect(self._open_help)
|
|
188
|
+
row.addWidget(self.help, 0, Qt.AlignmentFlag.AlignTop)
|
|
189
|
+
self.refresh_icons()
|
|
190
|
+
|
|
191
|
+
def set_page(self, spec: PageSpec) -> None:
|
|
192
|
+
self.title.setText(spec.title)
|
|
193
|
+
self.subtitle.setText(spec.subtitle)
|
|
194
|
+
self._help_anchor = spec.help
|
|
195
|
+
|
|
196
|
+
def refresh_icons(self) -> None:
|
|
197
|
+
self.help.setIcon(icon("help", current_palette().muted))
|
|
198
|
+
|
|
199
|
+
def _open_help(self) -> None:
|
|
200
|
+
QDesktopServices.openUrl(QUrl(README_URL + self._help_anchor))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class LoadingPage(QWidget):
|
|
204
|
+
"""Shown while a page is created for the first time."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
207
|
+
super().__init__(parent)
|
|
208
|
+
col = QVBoxLayout(self)
|
|
209
|
+
col.addStretch(2)
|
|
210
|
+
self.spinner = Spinner(36)
|
|
211
|
+
col.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignHCenter)
|
|
212
|
+
col.addSpacing(14)
|
|
213
|
+
self.message = label("", "section")
|
|
214
|
+
self.message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
215
|
+
col.addWidget(self.message)
|
|
216
|
+
hint = label("The first time a page opens, its libraries are loaded. This can take a few seconds.", "hint")
|
|
217
|
+
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
218
|
+
col.addWidget(hint)
|
|
219
|
+
col.addStretch(3)
|
|
220
|
+
|
|
221
|
+
def show_for(self, title: str) -> None:
|
|
222
|
+
self.message.setText(f"Loading {title}…")
|
|
223
|
+
self.spinner.set_color(current_palette().accent)
|
|
224
|
+
self.spinner.start()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Sidebar(QFrame):
|
|
228
|
+
"""App identity and navigation; ``navigate`` carries a page key, ``open_docs`` the documentation link."""
|
|
229
|
+
|
|
230
|
+
navigate = Signal(str)
|
|
231
|
+
open_docs = Signal()
|
|
232
|
+
|
|
233
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
234
|
+
super().__init__(parent)
|
|
235
|
+
self.setObjectName("Sidebar")
|
|
236
|
+
self.setFixedWidth(232)
|
|
237
|
+
col = QVBoxLayout(self)
|
|
238
|
+
col.setContentsMargins(14, 18, 14, 18)
|
|
239
|
+
col.setSpacing(4)
|
|
240
|
+
|
|
241
|
+
identity = QHBoxLayout()
|
|
242
|
+
identity.setSpacing(12)
|
|
243
|
+
app_icon = QLabel()
|
|
244
|
+
app_icon.setObjectName("AppIcon")
|
|
245
|
+
app_icon.setFixedSize(44, 44)
|
|
246
|
+
inner = QHBoxLayout(app_icon)
|
|
247
|
+
inner.setContentsMargins(0, 0, 0, 0)
|
|
248
|
+
inner.addWidget(IconLabel("activity", 24, "accent"), 0, Qt.AlignmentFlag.AlignCenter)
|
|
249
|
+
identity.addWidget(app_icon)
|
|
250
|
+
names = QVBoxLayout()
|
|
251
|
+
names.setSpacing(0)
|
|
252
|
+
names.addWidget(label("Nucleus-Kit", "appName"))
|
|
253
|
+
names.addWidget(label("Processing Toolkit", "hint"))
|
|
254
|
+
identity.addLayout(names, 1)
|
|
255
|
+
col.addLayout(identity)
|
|
256
|
+
col.addSpacing(18)
|
|
257
|
+
|
|
258
|
+
self._group = QButtonGroup(self)
|
|
259
|
+
self._group.setExclusive(True)
|
|
260
|
+
self.buttons: dict[str, QPushButton] = {}
|
|
261
|
+
for key in MAIN_NAV:
|
|
262
|
+
col.addWidget(self._make_button(key))
|
|
263
|
+
col.addStretch(1)
|
|
264
|
+
for key in BOTTOM_NAV:
|
|
265
|
+
col.addWidget(self._make_button(key))
|
|
266
|
+
self.refresh_icons()
|
|
267
|
+
|
|
268
|
+
def _make_button(self, key: str) -> QPushButton:
|
|
269
|
+
if key == "docs":
|
|
270
|
+
btn = QPushButton(" Documentation")
|
|
271
|
+
btn.clicked.connect(self.open_docs.emit)
|
|
272
|
+
else:
|
|
273
|
+
btn = QPushButton(" " + PAGES[key].nav_label)
|
|
274
|
+
btn.setCheckable(True)
|
|
275
|
+
self._group.addButton(btn)
|
|
276
|
+
btn.clicked.connect(lambda _=False, k=key: self.navigate.emit(k))
|
|
277
|
+
btn.setProperty("variant", "nav")
|
|
278
|
+
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
279
|
+
self.buttons[key] = btn
|
|
280
|
+
return btn
|
|
281
|
+
|
|
282
|
+
def set_current(self, key: str) -> None:
|
|
283
|
+
btn = self.buttons.get(key)
|
|
284
|
+
if btn is not None:
|
|
285
|
+
btn.setChecked(True)
|
|
286
|
+
|
|
287
|
+
def set_locked(self, locked: bool, current: str) -> None:
|
|
288
|
+
for key, btn in self.buttons.items():
|
|
289
|
+
btn.setEnabled(not locked or key in (current, "docs"))
|
|
290
|
+
|
|
291
|
+
def refresh_icons(self) -> None:
|
|
292
|
+
p = current_palette()
|
|
293
|
+
icons = {key: PAGES[key].icon for key in MAIN_NAV + ("settings", "about")}
|
|
294
|
+
icons["docs"] = "book"
|
|
295
|
+
for key, btn in self.buttons.items():
|
|
296
|
+
color = p.accent_text if btn.isChecked() else p.text
|
|
297
|
+
btn.setIcon(icon(icons[key], color, 18))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class MainWindow(QMainWindow):
|
|
301
|
+
def __init__(self) -> None:
|
|
302
|
+
super().__init__()
|
|
303
|
+
self.setWindowTitle("Nucleus-Kit Processing Toolkit")
|
|
304
|
+
self.setMinimumSize(1100, 720)
|
|
305
|
+
|
|
306
|
+
self._pages: dict[str, QWidget] = {}
|
|
307
|
+
self._current = ""
|
|
308
|
+
self._pending = ""
|
|
309
|
+
self._warm = False
|
|
310
|
+
self._import_thread: QThread | None = None
|
|
311
|
+
self._import_worker: _ImportWorker | None = None
|
|
312
|
+
self._after_import: list[Callable[[], None]] = []
|
|
313
|
+
|
|
314
|
+
self._sidebar = Sidebar()
|
|
315
|
+
self._sidebar.navigate.connect(self.show_page)
|
|
316
|
+
self._sidebar.open_docs.connect(lambda: QDesktopServices.openUrl(QUrl(DOCS_URL)))
|
|
317
|
+
|
|
318
|
+
self._header = PageHeader()
|
|
319
|
+
self._stack = QStackedWidget()
|
|
320
|
+
self._loading = LoadingPage()
|
|
321
|
+
self._stack.addWidget(self._loading)
|
|
322
|
+
|
|
323
|
+
content = QWidget()
|
|
324
|
+
content_col = QVBoxLayout(content)
|
|
325
|
+
content_col.setContentsMargins(32, 26, 32, 24)
|
|
326
|
+
content_col.setSpacing(22)
|
|
327
|
+
content_col.addWidget(self._header)
|
|
328
|
+
content_col.addWidget(self._stack, 1)
|
|
329
|
+
|
|
330
|
+
central = QWidget()
|
|
331
|
+
row = QHBoxLayout(central)
|
|
332
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
333
|
+
row.setSpacing(0)
|
|
334
|
+
row.addWidget(self._sidebar)
|
|
335
|
+
row.addWidget(content, 1)
|
|
336
|
+
self.setCentralWidget(central)
|
|
337
|
+
|
|
338
|
+
app = QApplication.instance()
|
|
339
|
+
if app is not None:
|
|
340
|
+
sh = app.styleHints()
|
|
341
|
+
if hasattr(sh, "colorSchemeChanged"):
|
|
342
|
+
sh.colorSchemeChanged.connect(self._on_system_color_scheme_changed)
|
|
343
|
+
|
|
344
|
+
self.show_page("process")
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------ #
|
|
347
|
+
# Navigation #
|
|
348
|
+
# ------------------------------------------------------------------ #
|
|
349
|
+
|
|
350
|
+
def show_page(self, key: str) -> None:
|
|
351
|
+
if key == self._current and key in self._pages:
|
|
352
|
+
self._sidebar.set_current(PAGES[key].nav or key)
|
|
353
|
+
return
|
|
354
|
+
current = self._pages.get(self._current)
|
|
355
|
+
can_leave = getattr(current, "can_leave", None)
|
|
356
|
+
if current is not None and callable(can_leave) and not can_leave():
|
|
357
|
+
self._sidebar.set_current(PAGES[self._current].nav or self._current)
|
|
358
|
+
self._sidebar.refresh_icons()
|
|
359
|
+
return
|
|
360
|
+
spec = PAGES[key]
|
|
361
|
+
self._header.set_page(spec)
|
|
362
|
+
self._sidebar.set_current(spec.nav or key)
|
|
363
|
+
self._sidebar.refresh_icons()
|
|
364
|
+
self._current = key
|
|
365
|
+
if key in self._pages:
|
|
366
|
+
self._stack.setCurrentWidget(self._pages[key])
|
|
367
|
+
return
|
|
368
|
+
self._pending = key
|
|
369
|
+
self._loading.show_for(spec.title)
|
|
370
|
+
self._stack.setCurrentWidget(self._loading)
|
|
371
|
+
self._run_imports(lambda: self._build_page(key))
|
|
372
|
+
|
|
373
|
+
def _build_page(self, key: str) -> None:
|
|
374
|
+
if key in self._pages:
|
|
375
|
+
return
|
|
376
|
+
spec = PAGES[key]
|
|
377
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
378
|
+
try:
|
|
379
|
+
module = importlib.import_module(spec.module)
|
|
380
|
+
page = getattr(module, spec.factory)()
|
|
381
|
+
finally:
|
|
382
|
+
QApplication.restoreOverrideCursor()
|
|
383
|
+
self._pages[key] = page
|
|
384
|
+
self._stack.addWidget(page)
|
|
385
|
+
self._connect_page(key, page)
|
|
386
|
+
if self._pending == key:
|
|
387
|
+
self._loading.spinner.stop()
|
|
388
|
+
self._stack.setCurrentWidget(page)
|
|
389
|
+
|
|
390
|
+
def _connect_page(self, key: str, page: QWidget) -> None:
|
|
391
|
+
if key == "tools":
|
|
392
|
+
for signal_name, target in TOOL_SIGNALS.items():
|
|
393
|
+
getattr(page, signal_name).connect(lambda t=target: self.show_page(t))
|
|
394
|
+
if hasattr(page, "go_tools_menu"):
|
|
395
|
+
page.go_tools_menu.connect(lambda: self.show_page("tools"))
|
|
396
|
+
if key == "settings":
|
|
397
|
+
page.theme_changed.connect(self._on_theme_changed)
|
|
398
|
+
if hasattr(page, "processing_changed"):
|
|
399
|
+
page.processing_changed.connect(lambda running, k=key: self._sidebar.set_locked(running, k))
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------ #
|
|
402
|
+
# Background imports #
|
|
403
|
+
# ------------------------------------------------------------------ #
|
|
404
|
+
|
|
405
|
+
def start_warmup(self) -> None:
|
|
406
|
+
"""Load the pages' libraries in the background (called once the window is visible)."""
|
|
407
|
+
self._run_imports(None)
|
|
408
|
+
|
|
409
|
+
def _run_imports(self, then: Callable[[], None] | None) -> None:
|
|
410
|
+
if then is not None:
|
|
411
|
+
if self._warm:
|
|
412
|
+
then()
|
|
413
|
+
return
|
|
414
|
+
self._after_import.append(then)
|
|
415
|
+
if self._import_thread is not None:
|
|
416
|
+
return
|
|
417
|
+
thread = QThread(self)
|
|
418
|
+
worker = _ImportWorker(WARMUP_MODULES)
|
|
419
|
+
worker.moveToThread(thread)
|
|
420
|
+
thread.started.connect(worker.run)
|
|
421
|
+
worker.done.connect(self._on_imports_done)
|
|
422
|
+
worker.done.connect(thread.quit)
|
|
423
|
+
thread.finished.connect(worker.deleteLater)
|
|
424
|
+
self._import_thread = thread
|
|
425
|
+
self._import_worker = worker
|
|
426
|
+
thread.start()
|
|
427
|
+
|
|
428
|
+
def _on_imports_done(self) -> None:
|
|
429
|
+
self._warm = True
|
|
430
|
+
callbacks, self._after_import = self._after_import, []
|
|
431
|
+
for callback in callbacks:
|
|
432
|
+
callback()
|
|
433
|
+
|
|
434
|
+
# ------------------------------------------------------------------ #
|
|
435
|
+
# Theme #
|
|
436
|
+
# ------------------------------------------------------------------ #
|
|
437
|
+
|
|
438
|
+
def _on_theme_changed(self, mode: str) -> None:
|
|
439
|
+
app = QApplication.instance()
|
|
440
|
+
if app is None:
|
|
441
|
+
return
|
|
442
|
+
apply_theme(app, cast(ThemeMode, mode))
|
|
443
|
+
self._refresh_theme()
|
|
444
|
+
|
|
445
|
+
def _on_system_color_scheme_changed(self, _scheme: Qt.ColorScheme | int) -> None:
|
|
446
|
+
if load_theme_setting() != "system":
|
|
447
|
+
return
|
|
448
|
+
app = QApplication.instance()
|
|
449
|
+
if app is not None:
|
|
450
|
+
apply_theme(app, "system")
|
|
451
|
+
self._refresh_theme()
|
|
452
|
+
|
|
453
|
+
def _refresh_theme(self) -> None:
|
|
454
|
+
self._sidebar.refresh_icons()
|
|
455
|
+
self._header.refresh_icons()
|
|
456
|
+
refresh_icons(self)
|
|
457
|
+
for page in self._pages.values():
|
|
458
|
+
hook = getattr(page, "refresh_theme", None)
|
|
459
|
+
if callable(hook):
|
|
460
|
+
hook()
|
|
461
|
+
|
|
462
|
+
def closeEvent(self, event) -> None: # type: ignore[no-untyped-def]
|
|
463
|
+
if self._import_thread is not None and self._import_thread.isRunning():
|
|
464
|
+
self._import_thread.wait(5000)
|
|
465
|
+
super().closeEvent(event)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Headless helpers for the offline pipeline UI (no Qt imports)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
|
|
9
|
+
from reak_core.io.session import ensure_rawdata_layout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueueTextWriter(io.TextIOBase):
|
|
13
|
+
"""Write stdout/stderr into a thread-safe queue as UTF-8 text."""
|
|
14
|
+
|
|
15
|
+
encoding = "utf-8"
|
|
16
|
+
|
|
17
|
+
def __init__(self, q: queue.SimpleQueue[str]):
|
|
18
|
+
super().__init__()
|
|
19
|
+
self._q = q
|
|
20
|
+
|
|
21
|
+
def write(self, s: str) -> int:
|
|
22
|
+
if s:
|
|
23
|
+
self._q.put(s)
|
|
24
|
+
return len(s) if isinstance(s, str) else 0
|
|
25
|
+
|
|
26
|
+
def flush(self) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def session_preflight(folder: str) -> str | None:
|
|
31
|
+
"""Return an error message if the folder is unsuitable, else None."""
|
|
32
|
+
if not folder:
|
|
33
|
+
return "Please select a session folder."
|
|
34
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
35
|
+
if not os.path.isdir(folder):
|
|
36
|
+
return "Session path is not a directory."
|
|
37
|
+
ensure_rawdata_layout(folder)
|
|
38
|
+
raw = os.path.join(folder, "rawData")
|
|
39
|
+
if not os.path.isdir(raw):
|
|
40
|
+
return (
|
|
41
|
+
"No rawData subfolder found. Expected a Nucleus-Kit session directory "
|
|
42
|
+
"with rawData (Hermes EEG/EMG and related files)."
|
|
43
|
+
)
|
|
44
|
+
if not os.listdir(raw):
|
|
45
|
+
return (
|
|
46
|
+
"No recording files found: rawData is empty after preparing the session folder."
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def dataset_preflight(folder: str) -> tuple[list[str], str | None]:
|
|
52
|
+
"""Return (valid_session_paths, error) for a dataset root folder.
|
|
53
|
+
|
|
54
|
+
A valid session subfolder is any direct child directory that either already
|
|
55
|
+
contains a rawData/ subdirectory or has recording files at its top level
|
|
56
|
+
(which are automatically moved into rawData/ by ensure_rawdata_layout).
|
|
57
|
+
The returned paths are sorted alphabetically.
|
|
58
|
+
"""
|
|
59
|
+
if not folder:
|
|
60
|
+
return [], "Please select a dataset root folder."
|
|
61
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
62
|
+
if not os.path.isdir(folder):
|
|
63
|
+
return [], "Dataset path is not a directory."
|
|
64
|
+
|
|
65
|
+
sessions: list[str] = []
|
|
66
|
+
try:
|
|
67
|
+
entries = sorted(os.listdir(folder))
|
|
68
|
+
except OSError as e:
|
|
69
|
+
return [], f"Cannot read dataset folder: {e}"
|
|
70
|
+
|
|
71
|
+
for entry in entries:
|
|
72
|
+
candidate = os.path.join(folder, entry)
|
|
73
|
+
if not os.path.isdir(candidate):
|
|
74
|
+
continue
|
|
75
|
+
ensure_rawdata_layout(candidate)
|
|
76
|
+
raw = os.path.join(candidate, "rawData")
|
|
77
|
+
if os.path.isdir(raw) and os.listdir(raw):
|
|
78
|
+
sessions.append(candidate)
|
|
79
|
+
|
|
80
|
+
if not sessions:
|
|
81
|
+
return [], (
|
|
82
|
+
"No session subfolders found. Expected direct child directories "
|
|
83
|
+
"containing recording files (or a rawData/ subfolder)."
|
|
84
|
+
)
|
|
85
|
+
return sessions, None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rms_features_preflight(folder: str) -> str | None:
|
|
89
|
+
"""Return an error message if RMS feature CSV is missing, else None."""
|
|
90
|
+
if not folder:
|
|
91
|
+
return "Please select a session folder."
|
|
92
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
93
|
+
if not os.path.isdir(folder):
|
|
94
|
+
return "Session path is not a directory."
|
|
95
|
+
rms = os.path.join(folder, "features", "emotions", "rmsSignals.csv")
|
|
96
|
+
if not os.path.isfile(rms):
|
|
97
|
+
return (
|
|
98
|
+
"Missing features/emotions/rmsSignals.csv. Run offline processing for this session first "
|
|
99
|
+
"so emotions features (including RMS) are produced."
|
|
100
|
+
)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def channel_fixer_preflight(folder: str) -> str | None:
|
|
105
|
+
"""Return an error message if the folder cannot be used for channel fixer, else None."""
|
|
106
|
+
return rms_features_preflight(folder)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ppg_fixer_preflight(folder: str) -> str | None:
|
|
110
|
+
"""Return an error message if the folder cannot be used for the PPG fixer, else None."""
|
|
111
|
+
if not folder:
|
|
112
|
+
return "Please select a session folder."
|
|
113
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
114
|
+
if not os.path.isdir(folder):
|
|
115
|
+
return "Session path is not a directory."
|
|
116
|
+
resampled = os.path.join(folder, "features", "ppg", "ppg_resampled.csv")
|
|
117
|
+
if not os.path.isfile(resampled):
|
|
118
|
+
return (
|
|
119
|
+
"Missing features/ppg/ppg_resampled.csv. Run offline processing for this session "
|
|
120
|
+
"first so PPG features are produced."
|
|
121
|
+
)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def eeg_regression_preflight(folder: str) -> str | None:
|
|
126
|
+
"""Return an error message if the folder cannot be used for EEG regression denoising, else None."""
|
|
127
|
+
if not folder:
|
|
128
|
+
return "Please select a session folder."
|
|
129
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
130
|
+
if not os.path.isdir(folder):
|
|
131
|
+
return "Session path is not a directory."
|
|
132
|
+
raw = os.path.join(folder, "rawData")
|
|
133
|
+
if not os.path.isdir(raw):
|
|
134
|
+
return (
|
|
135
|
+
"No rawData subfolder found. Expected a Nucleus-Kit session directory "
|
|
136
|
+
"with a raw EEG file (rawEEG_0.csv, eeg.csv, eeg.tmp, or eegRec_0.csv)."
|
|
137
|
+
)
|
|
138
|
+
eeg_filenames = ["rawEEG_0.csv", "eeg.tmp", "eeg.csv", "eegRec_0.csv"]
|
|
139
|
+
if not any(os.path.isfile(os.path.join(raw, f)) for f in eeg_filenames):
|
|
140
|
+
return (
|
|
141
|
+
"No raw EEG file found in rawData/. "
|
|
142
|
+
"Expected one of: " + ", ".join(eeg_filenames)
|
|
143
|
+
)
|
|
144
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Stacked UI pages."""
|