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