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,395 @@
|
|
|
1
|
+
"""Recording Merger: join up to 5 recordings with null-padded CSV gaps and black-screen video gaps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import queue
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
|
|
11
|
+
from PySide6.QtGui import QTextCursor
|
|
12
|
+
from PySide6.QtWidgets import (
|
|
13
|
+
QCheckBox,
|
|
14
|
+
QDoubleSpinBox,
|
|
15
|
+
QFileDialog,
|
|
16
|
+
QGroupBox,
|
|
17
|
+
QHBoxLayout,
|
|
18
|
+
QLabel,
|
|
19
|
+
QLineEdit,
|
|
20
|
+
QMessageBox,
|
|
21
|
+
QPlainTextEdit,
|
|
22
|
+
QProgressBar,
|
|
23
|
+
QPushButton,
|
|
24
|
+
QSizePolicy,
|
|
25
|
+
QVBoxLayout,
|
|
26
|
+
QWidget,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from nucleuskit_toolkit.tools.recording_merger import merge_recordings
|
|
30
|
+
from nucleuskit_toolkit.ui.offline_job import QueueTextWriter
|
|
31
|
+
|
|
32
|
+
_MAX_RECORDINGS = 5
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── worker ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
class _MergeWorker(QObject):
|
|
38
|
+
finished_ok = Signal()
|
|
39
|
+
finished_err = Signal(str)
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
folders: list[str],
|
|
44
|
+
offsets: list[float],
|
|
45
|
+
output_dir: str,
|
|
46
|
+
log_queue: queue.SimpleQueue[str],
|
|
47
|
+
skip_video: bool = False,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
self._folders = folders
|
|
51
|
+
self._offsets = offsets
|
|
52
|
+
self._output_dir = output_dir
|
|
53
|
+
self._log_queue = log_queue
|
|
54
|
+
self._skip_video = skip_video
|
|
55
|
+
|
|
56
|
+
@Slot()
|
|
57
|
+
def run(self) -> None:
|
|
58
|
+
err: str | None = None
|
|
59
|
+
writer = QueueTextWriter(self._log_queue)
|
|
60
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
61
|
+
try:
|
|
62
|
+
sys.stdout = writer
|
|
63
|
+
sys.stderr = writer
|
|
64
|
+
merge_recordings(
|
|
65
|
+
self._folders, self._offsets, self._output_dir,
|
|
66
|
+
self._log_queue, skip_video=self._skip_video,
|
|
67
|
+
)
|
|
68
|
+
except BaseException:
|
|
69
|
+
err = traceback.format_exc()
|
|
70
|
+
self._log_queue.put(err)
|
|
71
|
+
finally:
|
|
72
|
+
sys.stdout = old_out
|
|
73
|
+
sys.stderr = old_err
|
|
74
|
+
|
|
75
|
+
if err:
|
|
76
|
+
self.finished_err.emit("Run finished with errors (see log).")
|
|
77
|
+
else:
|
|
78
|
+
self.finished_ok.emit()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── recording row widget ────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
class _RecordingRow(QWidget):
|
|
84
|
+
"""One row: index label | path field | Browse | [Offset label | spin] | [Remove]."""
|
|
85
|
+
|
|
86
|
+
path_changed = Signal()
|
|
87
|
+
remove_requested = Signal(object) # emits self
|
|
88
|
+
|
|
89
|
+
def __init__(self, index: int, parent: QWidget | None = None) -> None:
|
|
90
|
+
super().__init__(parent)
|
|
91
|
+
self._index = index
|
|
92
|
+
|
|
93
|
+
self._label = QLabel(f"Recording {index + 1}:")
|
|
94
|
+
self._label.setFixedWidth(100)
|
|
95
|
+
|
|
96
|
+
self._path = QLineEdit()
|
|
97
|
+
self._path.setPlaceholderText("Recording folder path…")
|
|
98
|
+
self._path.textChanged.connect(lambda _: self.path_changed.emit())
|
|
99
|
+
|
|
100
|
+
self._browse = QPushButton("Browse…")
|
|
101
|
+
self._browse.clicked.connect(self._browse_clicked)
|
|
102
|
+
|
|
103
|
+
self._offset_label = QLabel("Offset:")
|
|
104
|
+
self._offset = QDoubleSpinBox()
|
|
105
|
+
self._offset.setRange(0.0, 86_400.0) # up to 24 h
|
|
106
|
+
self._offset.setSingleStep(1.0)
|
|
107
|
+
self._offset.setSuffix(" s")
|
|
108
|
+
self._offset.setValue(0.0)
|
|
109
|
+
self._offset.setFixedWidth(110)
|
|
110
|
+
self._offset.setToolTip("Gap in seconds from the end of the previous recording to the start of this one.")
|
|
111
|
+
|
|
112
|
+
self._remove = QPushButton("Remove")
|
|
113
|
+
self._remove.setProperty("secondary", True)
|
|
114
|
+
self._remove.clicked.connect(lambda: self.remove_requested.emit(self))
|
|
115
|
+
|
|
116
|
+
row = QHBoxLayout(self)
|
|
117
|
+
row.setContentsMargins(0, 2, 0, 2)
|
|
118
|
+
row.addWidget(self._label)
|
|
119
|
+
row.addWidget(self._path, 1)
|
|
120
|
+
row.addWidget(self._browse)
|
|
121
|
+
row.addWidget(self._offset_label)
|
|
122
|
+
row.addWidget(self._offset)
|
|
123
|
+
row.addWidget(self._remove)
|
|
124
|
+
|
|
125
|
+
self._refresh_visibility()
|
|
126
|
+
|
|
127
|
+
# ── public ────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def folder(self) -> str:
|
|
130
|
+
return self._path.text().strip()
|
|
131
|
+
|
|
132
|
+
def offset(self) -> float:
|
|
133
|
+
return self._offset.value()
|
|
134
|
+
|
|
135
|
+
def set_index(self, new_index: int) -> None:
|
|
136
|
+
self._index = new_index
|
|
137
|
+
self._label.setText(f"Recording {new_index + 1}:")
|
|
138
|
+
self._refresh_visibility()
|
|
139
|
+
|
|
140
|
+
def set_controls_enabled(self, enabled: bool) -> None:
|
|
141
|
+
self._path.setEnabled(enabled)
|
|
142
|
+
self._browse.setEnabled(enabled)
|
|
143
|
+
self._offset.setEnabled(enabled)
|
|
144
|
+
self._remove.setEnabled(enabled)
|
|
145
|
+
|
|
146
|
+
# ── private ───────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def _refresh_visibility(self) -> None:
|
|
149
|
+
is_first = self._index == 0
|
|
150
|
+
self._offset_label.setVisible(not is_first)
|
|
151
|
+
self._offset.setVisible(not is_first)
|
|
152
|
+
self._remove.setVisible(not is_first)
|
|
153
|
+
|
|
154
|
+
def _browse_clicked(self) -> None:
|
|
155
|
+
path = QFileDialog.getExistingDirectory(self, "Select recording folder")
|
|
156
|
+
if path:
|
|
157
|
+
self._path.setText(path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── main page ──────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
class RecordingMergePage(QWidget):
|
|
163
|
+
go_tools_menu = Signal()
|
|
164
|
+
processing_changed = Signal(bool)
|
|
165
|
+
|
|
166
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
167
|
+
super().__init__(parent)
|
|
168
|
+
|
|
169
|
+
self._log_queue: queue.SimpleQueue[str] = queue.SimpleQueue()
|
|
170
|
+
self._thread: QThread | None = None
|
|
171
|
+
self._worker: _MergeWorker | None = None
|
|
172
|
+
self._rows: list[_RecordingRow] = []
|
|
173
|
+
|
|
174
|
+
# ── top bar ───────────────────────────────────────────────────────────
|
|
175
|
+
self._back = QPushButton("Back")
|
|
176
|
+
self._back.setProperty("secondary", True)
|
|
177
|
+
self._back.clicked.connect(self.go_tools_menu.emit)
|
|
178
|
+
|
|
179
|
+
top = QHBoxLayout()
|
|
180
|
+
top.addStretch(1)
|
|
181
|
+
top.addWidget(self._back)
|
|
182
|
+
|
|
183
|
+
# ── recordings group ──────────────────────────────────────────────────
|
|
184
|
+
self._rows_layout = QVBoxLayout()
|
|
185
|
+
self._rows_layout.setSpacing(4)
|
|
186
|
+
|
|
187
|
+
self._add_btn = QPushButton("+ Add recording")
|
|
188
|
+
self._add_btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
189
|
+
self._add_btn.clicked.connect(self._add_row)
|
|
190
|
+
|
|
191
|
+
rows_inner = QVBoxLayout()
|
|
192
|
+
rows_inner.addLayout(self._rows_layout)
|
|
193
|
+
rows_inner.addWidget(self._add_btn)
|
|
194
|
+
|
|
195
|
+
recordings_box = QGroupBox("Recordings (2–5, in order)")
|
|
196
|
+
recordings_box.setLayout(rows_inner)
|
|
197
|
+
|
|
198
|
+
# ── output folder ─────────────────────────────────────────────────────
|
|
199
|
+
self._output = QLineEdit()
|
|
200
|
+
self._output.setReadOnly(True)
|
|
201
|
+
self._output.setPlaceholderText("Set Recording 1 path to auto-generate…")
|
|
202
|
+
|
|
203
|
+
out_row = QHBoxLayout()
|
|
204
|
+
out_row.addWidget(QLabel("Output folder (auto):"))
|
|
205
|
+
out_row.addWidget(self._output, 1)
|
|
206
|
+
|
|
207
|
+
# ── actions ───────────────────────────────────────────────────────────
|
|
208
|
+
self._skip_video = QCheckBox("Skip video merge")
|
|
209
|
+
self._skip_video.setToolTip(
|
|
210
|
+
"When checked, only result CSVs are merged; video files are ignored."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
self._run = QPushButton("Start Merge")
|
|
214
|
+
self._run.clicked.connect(self._run_clicked)
|
|
215
|
+
|
|
216
|
+
self._progress = QProgressBar()
|
|
217
|
+
self._progress.setRange(0, 0)
|
|
218
|
+
self._progress.setVisible(False)
|
|
219
|
+
|
|
220
|
+
actions = QHBoxLayout()
|
|
221
|
+
actions.addWidget(self._skip_video)
|
|
222
|
+
actions.addStretch(1)
|
|
223
|
+
actions.addWidget(self._run)
|
|
224
|
+
actions.addWidget(self._progress, 1)
|
|
225
|
+
|
|
226
|
+
# ── log ───────────────────────────────────────────────────────────────
|
|
227
|
+
self._log = QPlainTextEdit()
|
|
228
|
+
self._log.setReadOnly(True)
|
|
229
|
+
|
|
230
|
+
log_box = QGroupBox("Log")
|
|
231
|
+
lg = QVBoxLayout(log_box)
|
|
232
|
+
lg.addWidget(self._log)
|
|
233
|
+
|
|
234
|
+
# ── assemble ──────────────────────────────────────────────────────────
|
|
235
|
+
layout = QVBoxLayout(self)
|
|
236
|
+
layout.addLayout(top)
|
|
237
|
+
layout.addWidget(recordings_box)
|
|
238
|
+
layout.addLayout(out_row)
|
|
239
|
+
layout.addLayout(actions)
|
|
240
|
+
layout.addWidget(log_box, 1)
|
|
241
|
+
|
|
242
|
+
# seed with two rows so the tool is immediately usable
|
|
243
|
+
self._add_row()
|
|
244
|
+
self._add_row()
|
|
245
|
+
|
|
246
|
+
self._poll_timer = QTimer(self)
|
|
247
|
+
self._poll_timer.timeout.connect(self._drain_log_queue)
|
|
248
|
+
self._poll_timer.start(120)
|
|
249
|
+
|
|
250
|
+
# ── row management ────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def _add_row(self) -> None:
|
|
253
|
+
if len(self._rows) >= _MAX_RECORDINGS:
|
|
254
|
+
return
|
|
255
|
+
row = _RecordingRow(len(self._rows), self)
|
|
256
|
+
row.path_changed.connect(self._update_output_folder)
|
|
257
|
+
row.remove_requested.connect(self._remove_row)
|
|
258
|
+
self._rows_layout.addWidget(row)
|
|
259
|
+
self._rows.append(row)
|
|
260
|
+
self._add_btn.setEnabled(len(self._rows) < _MAX_RECORDINGS)
|
|
261
|
+
|
|
262
|
+
def _remove_row(self, row: _RecordingRow) -> None:
|
|
263
|
+
if row not in self._rows:
|
|
264
|
+
return
|
|
265
|
+
self._rows.remove(row)
|
|
266
|
+
self._rows_layout.removeWidget(row)
|
|
267
|
+
row.deleteLater()
|
|
268
|
+
for i, r in enumerate(self._rows):
|
|
269
|
+
r.set_index(i)
|
|
270
|
+
self._add_btn.setEnabled(len(self._rows) < _MAX_RECORDINGS)
|
|
271
|
+
self._update_output_folder()
|
|
272
|
+
|
|
273
|
+
def _update_output_folder(self) -> None:
|
|
274
|
+
if not self._rows:
|
|
275
|
+
self._output.setText("")
|
|
276
|
+
return
|
|
277
|
+
first_path = self._rows[0].folder()
|
|
278
|
+
if not first_path:
|
|
279
|
+
self._output.setText("")
|
|
280
|
+
return
|
|
281
|
+
names = [Path(r.folder()).name for r in self._rows if r.folder()]
|
|
282
|
+
merged_name = "merged_" + "_".join(names) if names else "merged_recording"
|
|
283
|
+
self._output.setText(str(Path(first_path).parent / merged_name))
|
|
284
|
+
|
|
285
|
+
# ── validation ────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _validate(self) -> str | None:
|
|
288
|
+
if len(self._rows) < 2:
|
|
289
|
+
return "Add at least 2 recordings to merge."
|
|
290
|
+
|
|
291
|
+
for i, row in enumerate(self._rows):
|
|
292
|
+
f = row.folder()
|
|
293
|
+
if not f:
|
|
294
|
+
return f"Recording {i + 1} has no folder selected."
|
|
295
|
+
p = Path(f)
|
|
296
|
+
if not p.is_dir():
|
|
297
|
+
return f"Recording {i + 1}: folder does not exist:\n{f}"
|
|
298
|
+
if not (p / "results").is_dir() and not (p / "rawData").is_dir():
|
|
299
|
+
return (
|
|
300
|
+
f"Recording {i + 1}: folder does not look like a recording\n"
|
|
301
|
+
f"(no 'results/' or 'rawData/' subfolder found):\n{f}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
output = self._output.text().strip()
|
|
305
|
+
if not output:
|
|
306
|
+
return "Cannot determine output folder. Set Recording 1 path first."
|
|
307
|
+
if Path(output).exists():
|
|
308
|
+
return (
|
|
309
|
+
f"Output folder already exists:\n{output}\n\n"
|
|
310
|
+
"Remove or rename it before merging."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
# ── run ───────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def _run_clicked(self) -> None:
|
|
318
|
+
err = self._validate()
|
|
319
|
+
if err:
|
|
320
|
+
QMessageBox.warning(self, "Recording Merger", err)
|
|
321
|
+
return
|
|
322
|
+
if self._thread is not None and self._thread.isRunning():
|
|
323
|
+
QMessageBox.information(self, "Busy", "A merge is already in progress.")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
folders = [row.folder() for row in self._rows]
|
|
327
|
+
offsets = [row.offset() for row in self._rows[1:]]
|
|
328
|
+
output_dir = self._output.text().strip()
|
|
329
|
+
skip_video = self._skip_video.isChecked()
|
|
330
|
+
|
|
331
|
+
self._set_processing(True)
|
|
332
|
+
self._insert_log(
|
|
333
|
+
f"\n--- Starting merge of {len(folders)} recordings ---\n"
|
|
334
|
+
+ "".join(f" [{i + 1}] {f}\n" for i, f in enumerate(folders))
|
|
335
|
+
+ (" (video merge skipped)\n" if skip_video else "")
|
|
336
|
+
+ "\n"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
thread = QThread()
|
|
340
|
+
worker = _MergeWorker(folders, offsets, output_dir, self._log_queue, skip_video)
|
|
341
|
+
worker.moveToThread(thread)
|
|
342
|
+
|
|
343
|
+
thread.started.connect(worker.run)
|
|
344
|
+
worker.finished_ok.connect(self._on_finished_ok)
|
|
345
|
+
worker.finished_err.connect(self._on_finished_err)
|
|
346
|
+
worker.finished_ok.connect(thread.quit)
|
|
347
|
+
worker.finished_err.connect(thread.quit)
|
|
348
|
+
thread.finished.connect(worker.deleteLater)
|
|
349
|
+
thread.finished.connect(thread.deleteLater)
|
|
350
|
+
thread.finished.connect(self._clear_thread_ref)
|
|
351
|
+
|
|
352
|
+
self._thread = thread
|
|
353
|
+
self._worker = worker
|
|
354
|
+
thread.start()
|
|
355
|
+
|
|
356
|
+
def _set_processing(self, running: bool) -> None:
|
|
357
|
+
self._back.setEnabled(not running)
|
|
358
|
+
self._run.setEnabled(not running)
|
|
359
|
+
self._add_btn.setEnabled(not running)
|
|
360
|
+
self._skip_video.setEnabled(not running)
|
|
361
|
+
self._progress.setVisible(running)
|
|
362
|
+
for row in self._rows:
|
|
363
|
+
row.set_controls_enabled(not running)
|
|
364
|
+
self.processing_changed.emit(running)
|
|
365
|
+
|
|
366
|
+
def _clear_thread_ref(self) -> None:
|
|
367
|
+
self._thread = None
|
|
368
|
+
self._worker = None
|
|
369
|
+
|
|
370
|
+
def _on_finished_ok(self) -> None:
|
|
371
|
+
self._set_processing(False)
|
|
372
|
+
QMessageBox.information(
|
|
373
|
+
self,
|
|
374
|
+
"Recording Merger",
|
|
375
|
+
f"Done.\n\nMerged recording saved to:\n{self._output.text()}",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _on_finished_err(self, msg: str) -> None:
|
|
379
|
+
self._set_processing(False)
|
|
380
|
+
QMessageBox.critical(self, "Recording Merger", msg)
|
|
381
|
+
|
|
382
|
+
# ── log helpers ───────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
def _insert_log(self, text: str) -> None:
|
|
385
|
+
self._log.moveCursor(QTextCursor.MoveOperation.End)
|
|
386
|
+
self._log.insertPlainText(text)
|
|
387
|
+
sb = self._log.verticalScrollBar()
|
|
388
|
+
sb.setValue(sb.maximum())
|
|
389
|
+
|
|
390
|
+
def _drain_log_queue(self) -> None:
|
|
391
|
+
try:
|
|
392
|
+
while True:
|
|
393
|
+
self._insert_log(self._log_queue.get_nowait())
|
|
394
|
+
except queue.Empty:
|
|
395
|
+
pass
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Revert working RMS CSV to the frozen baseline in ``features/emotions/original/``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Signal
|
|
8
|
+
from PySide6.QtWidgets import (
|
|
9
|
+
QFileDialog,
|
|
10
|
+
QGroupBox,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QMessageBox,
|
|
14
|
+
QPlainTextEdit,
|
|
15
|
+
QPushButton,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
from reak_core.tools.rms_ops import (
|
|
20
|
+
operations_log_path,
|
|
21
|
+
original_rms_csv,
|
|
22
|
+
revert_working_to_original,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from nucleuskit_toolkit.ui.offline_job import rms_features_preflight
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RevertOriginalPage(QWidget):
|
|
29
|
+
go_tools_menu = Signal()
|
|
30
|
+
|
|
31
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
32
|
+
super().__init__(parent)
|
|
33
|
+
|
|
34
|
+
self._back = QPushButton("Back")
|
|
35
|
+
self._back.setProperty("secondary", True)
|
|
36
|
+
self._back.clicked.connect(self.go_tools_menu.emit)
|
|
37
|
+
|
|
38
|
+
top = QHBoxLayout()
|
|
39
|
+
top.addStretch(1)
|
|
40
|
+
top.addWidget(self._back)
|
|
41
|
+
|
|
42
|
+
self._session = QLabel()
|
|
43
|
+
self._session.setWordWrap(True)
|
|
44
|
+
self._session.setText("No session selected.")
|
|
45
|
+
|
|
46
|
+
self._browse = QPushButton("Select session folder…")
|
|
47
|
+
self._browse.clicked.connect(self._browse_session)
|
|
48
|
+
|
|
49
|
+
self._revert = QPushButton("Revert to original")
|
|
50
|
+
self._revert.clicked.connect(self._revert_clicked)
|
|
51
|
+
|
|
52
|
+
sess_box = QGroupBox("Session")
|
|
53
|
+
sg = QVBoxLayout(sess_box)
|
|
54
|
+
sg.addWidget(self._browse)
|
|
55
|
+
sg.addWidget(self._session)
|
|
56
|
+
|
|
57
|
+
self._log = QPlainTextEdit()
|
|
58
|
+
self._log.setReadOnly(True)
|
|
59
|
+
self._log.setPlaceholderText("operations.log tail (after revert)…")
|
|
60
|
+
|
|
61
|
+
log_box = QGroupBox("Recent operations log")
|
|
62
|
+
lg = QVBoxLayout(log_box)
|
|
63
|
+
lg.addWidget(self._log)
|
|
64
|
+
|
|
65
|
+
layout = QVBoxLayout(self)
|
|
66
|
+
layout.addLayout(top)
|
|
67
|
+
layout.addWidget(sess_box)
|
|
68
|
+
layout.addWidget(self._revert)
|
|
69
|
+
layout.addWidget(log_box, 1)
|
|
70
|
+
|
|
71
|
+
self._folder: str | None = None
|
|
72
|
+
|
|
73
|
+
def _browse_session(self) -> None:
|
|
74
|
+
path = QFileDialog.getExistingDirectory(self, "Select session folder")
|
|
75
|
+
if path:
|
|
76
|
+
self._folder = path
|
|
77
|
+
self._session.setText(path)
|
|
78
|
+
self._refresh_log_tail()
|
|
79
|
+
|
|
80
|
+
def _refresh_log_tail(self) -> None:
|
|
81
|
+
if not self._folder:
|
|
82
|
+
self._log.clear()
|
|
83
|
+
return
|
|
84
|
+
log_path = operations_log_path(self._folder)
|
|
85
|
+
if not os.path.isfile(log_path):
|
|
86
|
+
self._log.clear()
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
with open(log_path, encoding="utf-8") as fh:
|
|
90
|
+
lines = fh.readlines()
|
|
91
|
+
tail = "".join(lines[-80:])
|
|
92
|
+
self._log.setPlainText(tail)
|
|
93
|
+
except OSError:
|
|
94
|
+
self._log.setPlainText("(Could not read log.)")
|
|
95
|
+
|
|
96
|
+
def _revert_clicked(self) -> None:
|
|
97
|
+
if not self._folder:
|
|
98
|
+
QMessageBox.warning(self, "Session", "Please select a session folder.")
|
|
99
|
+
return
|
|
100
|
+
err = rms_features_preflight(self._folder)
|
|
101
|
+
if err:
|
|
102
|
+
QMessageBox.warning(self, "Session", err)
|
|
103
|
+
return
|
|
104
|
+
ori = original_rms_csv(self._folder)
|
|
105
|
+
if not ori.is_file():
|
|
106
|
+
QMessageBox.warning(
|
|
107
|
+
self,
|
|
108
|
+
"Revert",
|
|
109
|
+
f"No frozen baseline found:\n{ori}\n\n"
|
|
110
|
+
"Run a tool that writes RMS (channel fixer or gain adjustment) first.",
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
confirm = QMessageBox.question(
|
|
114
|
+
self,
|
|
115
|
+
"Revert to original",
|
|
116
|
+
"Overwrite features/emotions/rmsSignals.csv with the frozen baseline?",
|
|
117
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
118
|
+
QMessageBox.StandardButton.No,
|
|
119
|
+
)
|
|
120
|
+
if confirm != QMessageBox.StandardButton.Yes:
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
revert_working_to_original(self._folder)
|
|
124
|
+
except OSError as e:
|
|
125
|
+
QMessageBox.critical(self, "Revert", str(e))
|
|
126
|
+
return
|
|
127
|
+
self._refresh_log_tail()
|
|
128
|
+
QMessageBox.information(self, "Revert", "Working RMS file restored from original.")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Appearance and other settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QSettings, Signal
|
|
6
|
+
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QVBoxLayout, QWidget
|
|
7
|
+
|
|
8
|
+
from nucleuskit_toolkit.ui.theme import ThemeMode
|
|
9
|
+
from nucleuskit_toolkit.ui.widgets import Card, SectionHeader, label
|
|
10
|
+
|
|
11
|
+
ORGANIZATION = "REAK"
|
|
12
|
+
APPLICATION = "NucleusKitToolkit"
|
|
13
|
+
SETTINGS_THEME_KEY = "appearance/theme"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_theme_setting() -> ThemeMode:
|
|
17
|
+
s = QSettings(ORGANIZATION, APPLICATION)
|
|
18
|
+
raw = s.value(SETTINGS_THEME_KEY, "system")
|
|
19
|
+
if raw in ("light", "dark", "system"):
|
|
20
|
+
return raw # type: ignore[return-value]
|
|
21
|
+
return "system"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save_theme_setting(mode: ThemeMode) -> None:
|
|
25
|
+
s = QSettings(ORGANIZATION, APPLICATION)
|
|
26
|
+
s.setValue(SETTINGS_THEME_KEY, mode)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SettingsPage(QWidget):
|
|
30
|
+
"""Theme selection persisted with QSettings."""
|
|
31
|
+
|
|
32
|
+
theme_changed = Signal(str)
|
|
33
|
+
|
|
34
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
35
|
+
super().__init__(parent)
|
|
36
|
+
self._theme_combo = QComboBox()
|
|
37
|
+
self._theme_combo.addItem("Light", "light")
|
|
38
|
+
self._theme_combo.addItem("Dark", "dark")
|
|
39
|
+
self._theme_combo.addItem("System", "system")
|
|
40
|
+
self._theme_combo.setMinimumWidth(220)
|
|
41
|
+
|
|
42
|
+
current = load_theme_setting()
|
|
43
|
+
idx = max(0, self._theme_combo.findData(current))
|
|
44
|
+
self._theme_combo.setCurrentIndex(idx)
|
|
45
|
+
|
|
46
|
+
appearance = Card()
|
|
47
|
+
appearance.setMaximumWidth(760)
|
|
48
|
+
appearance.body.addWidget(SectionHeader("Appearance", "Colours of the application.", icon="settings"))
|
|
49
|
+
row = QHBoxLayout()
|
|
50
|
+
text = QVBoxLayout()
|
|
51
|
+
text.setSpacing(2)
|
|
52
|
+
text.addWidget(label("Theme", "strong"))
|
|
53
|
+
text.addWidget(label("System follows the light or dark mode of the operating system.", "hint"))
|
|
54
|
+
row.addLayout(text, 1)
|
|
55
|
+
row.addWidget(self._theme_combo)
|
|
56
|
+
appearance.body.addLayout(row)
|
|
57
|
+
|
|
58
|
+
layout = QVBoxLayout(self)
|
|
59
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
60
|
+
layout.addWidget(appearance)
|
|
61
|
+
layout.addStretch(1)
|
|
62
|
+
|
|
63
|
+
self._theme_combo.currentIndexChanged.connect(self._on_theme_changed)
|
|
64
|
+
|
|
65
|
+
def _on_theme_changed(self, _index: int) -> None:
|
|
66
|
+
mode = self._theme_combo.currentData()
|
|
67
|
+
if mode:
|
|
68
|
+
save_theme_setting(mode)
|
|
69
|
+
self.theme_changed.emit(mode)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Auxiliary tools for processed sessions (Channel Fixer, …), shown as a grid of cards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Signal
|
|
6
|
+
from PySide6.QtWidgets import QGridLayout, QVBoxLayout, QWidget
|
|
7
|
+
|
|
8
|
+
from nucleuskit_toolkit.ui.widgets import ChoiceCard
|
|
9
|
+
|
|
10
|
+
TOOLS = (
|
|
11
|
+
("open_channel_fixer", "Channel Fixer", "Repair a single bad RMS channel with the channel-fix model."),
|
|
12
|
+
("open_channel_gain", "Channel gain adjustment", "Adjust per-channel RMS gain and zero offset."),
|
|
13
|
+
("open_revert_original", "Revert to original", "Restore the RMS features from the frozen baseline."),
|
|
14
|
+
("open_ppg_fixer", "PPG Fixer", "Re-run PPG artifact rejection and heart dynamics."),
|
|
15
|
+
("open_eeg_regression", "EEG Regression Denoising", "Remove shared muscle noise from the EEG."),
|
|
16
|
+
("open_recording_merge", "Recording Merger", "Join up to 5 recordings into one session."),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolsMenuPage(QWidget):
|
|
21
|
+
open_channel_fixer = Signal()
|
|
22
|
+
open_channel_gain = Signal()
|
|
23
|
+
open_revert_original = Signal()
|
|
24
|
+
open_ppg_fixer = Signal()
|
|
25
|
+
open_eeg_regression = Signal()
|
|
26
|
+
open_recording_merge = Signal()
|
|
27
|
+
|
|
28
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
29
|
+
super().__init__(parent)
|
|
30
|
+
grid = QGridLayout()
|
|
31
|
+
grid.setHorizontalSpacing(14)
|
|
32
|
+
grid.setVerticalSpacing(14)
|
|
33
|
+
for i, (signal_name, title, description) in enumerate(TOOLS):
|
|
34
|
+
card = ChoiceCard("wrench", title, description)
|
|
35
|
+
card.set_indicator_visible(False)
|
|
36
|
+
card.clicked.connect(getattr(self, signal_name).emit)
|
|
37
|
+
grid.addWidget(card, i // 2, i % 2)
|
|
38
|
+
|
|
39
|
+
outer = QVBoxLayout(self)
|
|
40
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
41
|
+
outer.addLayout(grid)
|
|
42
|
+
outer.addStretch(1)
|