misleep 0.3.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.
- misleep/__init__.py +111 -0
- misleep/__main__.py +16 -0
- misleep/analysis/__init__.py +23 -0
- misleep/analysis/auto_stage.py +158 -0
- misleep/analysis/detection.py +198 -0
- misleep/analysis/features.py +131 -0
- misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/models/__init__.py +0 -0
- misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/transformer/__init__.py +46 -0
- misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
- misleep/analysis/transformer/checkpoints/__init__.py +0 -0
- misleep/analysis/transformer/configs.py +52 -0
- misleep/analysis/transformer/data.py +362 -0
- misleep/analysis/transformer/inference.py +328 -0
- misleep/analysis/transformer/models.py +220 -0
- misleep/analysis/transformer/preprocessing.py +105 -0
- misleep/config/__init__.py +105 -0
- misleep/config/default_config.ini +38 -0
- misleep/data/__init__.py +13 -0
- misleep/data/annotation.py +114 -0
- misleep/data/midata.py +387 -0
- misleep/gui/__init__.py +23 -0
- misleep/gui/app.py +183 -0
- misleep/gui/config_dialog.py +429 -0
- misleep/gui/dialogs.py +1107 -0
- misleep/gui/event_filters.py +26 -0
- misleep/gui/main_window.py +2699 -0
- misleep/gui/qt_utils.py +277 -0
- misleep/gui/resources/__init__.py +9 -0
- misleep/gui/resources/entire_logo.png +0 -0
- misleep/gui/resources/logo.png +0 -0
- misleep/gui/resources/misleep.ico +0 -0
- misleep/gui/resources/misleep.qrc +7 -0
- misleep/gui/resources/misleep_rc.py +1324 -0
- misleep/gui/spec_window.py +187 -0
- misleep/gui/style.py +994 -0
- misleep/gui/uis/SWA_detect_dialog.ui +181 -0
- misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
- misleep/gui/uis/__init__.py +6 -0
- misleep/gui/uis/about.ui +88 -0
- misleep/gui/uis/about_ui.py +80 -0
- misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
- misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
- misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
- misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
- misleep/gui/uis/horizontal_line_dialog.ui +166 -0
- misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
- misleep/gui/uis/label_dialog.ui +65 -0
- misleep/gui/uis/label_dialog_ui.py +72 -0
- misleep/gui/uis/main_window.ui +871 -0
- misleep/gui/uis/main_window_ui.py +616 -0
- misleep/gui/uis/save_data_dialog.ui +95 -0
- misleep/gui/uis/save_data_dialog_ui.py +98 -0
- misleep/gui/uis/spec_window.ui +109 -0
- misleep/gui/uis/spec_window_ui.py +103 -0
- misleep/gui/uis/spindle_detect_dialog.ui +202 -0
- misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
- misleep/gui/uis/state_spectral_dialog.ui +240 -0
- misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
- misleep/gui/uis/transfer_result_dialog.ui +104 -0
- misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
- misleep/gui/workers.py +69 -0
- misleep/io/__init__.py +68 -0
- misleep/io/annotation.py +367 -0
- misleep/io/array.py +209 -0
- misleep/io/base.py +174 -0
- misleep/io/edf.py +109 -0
- misleep/io/mat.py +189 -0
- misleep/logger.py +79 -0
- misleep/preprocessing/__init__.py +23 -0
- misleep/preprocessing/artifacts.py +76 -0
- misleep/preprocessing/filtering.py +94 -0
- misleep/preprocessing/segment.py +59 -0
- misleep/preprocessing/spectral.py +169 -0
- misleep/utils/__init__.py +45 -0
- misleep/utils/annotation.py +130 -0
- misleep/utils/entropy.py +135 -0
- misleep/utils/misc.py +69 -0
- misleep/utils/time_utils.py +61 -0
- misleep/viz/__init__.py +12 -0
- misleep/viz/hypnogram.py +48 -0
- misleep/viz/signals.py +51 -0
- misleep/viz/spectral.py +98 -0
- misleep-0.3.0.dist-info/METADATA +249 -0
- misleep-0.3.0.dist-info/RECORD +94 -0
- misleep-0.3.0.dist-info/WHEEL +5 -0
- misleep-0.3.0.dist-info/entry_points.txt +15 -0
- misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
- misleep-0.3.0.dist-info/top_level.txt +1 -0
misleep/gui/dialogs.py
ADDED
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Dialog windows of the MiSleep GUI.
|
|
3
|
+
|
|
4
|
+
Contains the label picker, result transfer dialog, state spectral
|
|
5
|
+
analysis dialog, horizontal-line dialog, SWA/spindle detection dialogs,
|
|
6
|
+
the two auto-staging dialogs and the save-data dialog, plus the About box.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import os
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from PySide6.QtCore import Qt, QStringListModel
|
|
15
|
+
from PySide6.QtGui import QColor
|
|
16
|
+
from PySide6.QtWidgets import (
|
|
17
|
+
QColorDialog,
|
|
18
|
+
QDialog,
|
|
19
|
+
QFileDialog,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QInputDialog,
|
|
22
|
+
QLabel,
|
|
23
|
+
QListWidget,
|
|
24
|
+
QMessageBox,
|
|
25
|
+
QPushButton,
|
|
26
|
+
QVBoxLayout,
|
|
27
|
+
QWidget,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from misleep.config import save_config
|
|
31
|
+
from misleep.gui.qt_utils import (
|
|
32
|
+
app_icon,
|
|
33
|
+
cal_draw_spectrum,
|
|
34
|
+
downsample_by_most_frequent,
|
|
35
|
+
get_base_path,
|
|
36
|
+
)
|
|
37
|
+
from misleep.gui.uis.about_ui import Ui_AboutDialog
|
|
38
|
+
from misleep.gui.uis.auto_stage_causalTransformer_dialog_ui import Ui_AutoStageCausalTransformerDialog
|
|
39
|
+
from misleep.gui.uis.auto_stage_lightGBM_dialog_ui import Ui_AutoStageLightGBMDialog
|
|
40
|
+
from misleep.gui.uis.horizontal_line_dialog_ui import Ui_horizontal_line_dialog
|
|
41
|
+
from misleep.gui.uis.label_dialog_ui import Ui_Dialog
|
|
42
|
+
from misleep.gui.uis.save_data_dialog_ui import Ui_SaveDataDialog
|
|
43
|
+
from misleep.gui.uis.spindle_detect_dialog_ui import Ui_SpindleDetectDialog
|
|
44
|
+
from misleep.gui.uis.state_spectral_dialog_ui import Ui_StateSpectralDialog
|
|
45
|
+
from misleep.gui.uis.SWA_detect_dialog_ui import Ui_SWADetectDialog
|
|
46
|
+
from misleep.gui.uis.transfer_result_dialog_ui import Ui_TransferResultDialog
|
|
47
|
+
from misleep.gui.workers import SaveThread
|
|
48
|
+
from misleep.io.annotation import transfer_result
|
|
49
|
+
from misleep.logger import logger
|
|
50
|
+
from misleep.utils.annotation import lst2group
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AboutDialog(QDialog, Ui_AboutDialog):
|
|
54
|
+
"""The About box of MiSleep."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, parent=None, version=None, update_time=None):
|
|
57
|
+
super().__init__(parent)
|
|
58
|
+
self.setupUi(self)
|
|
59
|
+
if version:
|
|
60
|
+
self.VersionLabel.setText(f"Version: {version}")
|
|
61
|
+
if update_time:
|
|
62
|
+
self.UpdateLabel.setText(f"Update: {update_time}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UserGuideDialog(QDialog):
|
|
66
|
+
"""A short in-app user guide with a link to the online documentation."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, parent=None):
|
|
69
|
+
super().__init__(parent)
|
|
70
|
+
self.setWindowTitle("MiSleep - User Guide")
|
|
71
|
+
self.setWindowIcon(app_icon())
|
|
72
|
+
self.resize(600, 460)
|
|
73
|
+
|
|
74
|
+
layout = QVBoxLayout(self)
|
|
75
|
+
layout.setSpacing(12)
|
|
76
|
+
layout.setContentsMargins(24, 20, 24, 20)
|
|
77
|
+
|
|
78
|
+
title = QLabel("<h2>MiSleep User Guide</h2>")
|
|
79
|
+
layout.addWidget(title)
|
|
80
|
+
|
|
81
|
+
steps = (
|
|
82
|
+
"1. <b>Load data</b> - File → Load Data (or drag a "
|
|
83
|
+
".mat / .edf file onto the window). Optional: File → Load "
|
|
84
|
+
"Annotation.<br>",
|
|
85
|
+
"2. <b>Browse</b> - use the scroll bar, the arrow keys "
|
|
86
|
+
"(←/→ page, ↑/↓ 5 s epoch) or the mouse "
|
|
87
|
+
"wheel over the signal / hypnogram panels.<br>",
|
|
88
|
+
"3. <b>Score</b> - Marker / Start-End / Sleep state modes on the "
|
|
89
|
+
"right sidebar; press the state buttons or number keys "
|
|
90
|
+
"1-9 (0 for state 10) to label the selected area.<br>",
|
|
91
|
+
"4. <b>Tools</b> - Filter, channel scaler/shift, spectrogram, "
|
|
92
|
+
"SWA / spindle detection and auto staging under the Tools menu.<br>",
|
|
93
|
+
"5. <b>Export</b> - Result → State Spectral / Transfer Result "
|
|
94
|
+
"saves Excel/PDF reports; File → Save Data exports the "
|
|
95
|
+
"signals.<br>",
|
|
96
|
+
"6. <b>Appearance</b> - Settings → Theme (light/dark) and "
|
|
97
|
+
"Color scheme; Ctrl+Shift+T toggles light/dark.<br>",
|
|
98
|
+
)
|
|
99
|
+
text = QLabel("".join(steps))
|
|
100
|
+
text.setWordWrap(True)
|
|
101
|
+
text.setTextFormat(Qt.TextFormat.RichText)
|
|
102
|
+
layout.addWidget(text)
|
|
103
|
+
|
|
104
|
+
layout.addSpacing(8)
|
|
105
|
+
link = QLabel(
|
|
106
|
+
'Full documentation: <a href="https://bryanwang.cn/MiSleep/">'
|
|
107
|
+
'https://bryanwang.cn/MiSleep/</a>')
|
|
108
|
+
link.setOpenExternalLinks(True)
|
|
109
|
+
link.setTextFormat(Qt.TextFormat.RichText)
|
|
110
|
+
layout.addWidget(link)
|
|
111
|
+
|
|
112
|
+
layout.addStretch(1)
|
|
113
|
+
close_row = QHBoxLayout()
|
|
114
|
+
close_row.addStretch(1)
|
|
115
|
+
close_bt = QPushButton("Close")
|
|
116
|
+
close_bt.clicked.connect(self.accept)
|
|
117
|
+
close_row.addWidget(close_bt)
|
|
118
|
+
layout.addLayout(close_row)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class LabelDialog(QDialog, Ui_Dialog):
|
|
122
|
+
"""Pick a marker / start-end label from the configured lists."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, parent=None, config=None):
|
|
125
|
+
super().__init__(parent)
|
|
126
|
+
self.setupUi(self)
|
|
127
|
+
|
|
128
|
+
self.config = config
|
|
129
|
+
|
|
130
|
+
# Type representing marker(0) or start_end(1)
|
|
131
|
+
self._type = 0
|
|
132
|
+
|
|
133
|
+
self.slm = QStringListModel()
|
|
134
|
+
self.LabelListView.setModel(self.slm)
|
|
135
|
+
self.marker_label = [each[1:-1] for each in
|
|
136
|
+
self.config["gui"]["marker"][1:-1].split(", ")]
|
|
137
|
+
self.start_end_label = [each[1:-1] for each in
|
|
138
|
+
self.config["gui"]["startend"][1:-1].split(", ")]
|
|
139
|
+
self.label_name = ""
|
|
140
|
+
self.closed = False
|
|
141
|
+
|
|
142
|
+
self.OKBt.clicked.connect(self.submit_label)
|
|
143
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
144
|
+
self.AddBt.clicked.connect(self.add_label)
|
|
145
|
+
self.DeleteBt.clicked.connect(self.delete_label)
|
|
146
|
+
|
|
147
|
+
self.slm.dataChanged.connect(self.update_label_list)
|
|
148
|
+
self.add_or_delete = False
|
|
149
|
+
|
|
150
|
+
def show_contents(self, idx=0):
|
|
151
|
+
"""Show the label list for the current type."""
|
|
152
|
+
self.closed = False
|
|
153
|
+
|
|
154
|
+
if self._type == 0:
|
|
155
|
+
self.slm.setStringList(self.marker_label)
|
|
156
|
+
self.LabelListView.setModel(self.slm)
|
|
157
|
+
if self._type == 1:
|
|
158
|
+
self.slm.setStringList(self.start_end_label)
|
|
159
|
+
self.LabelListView.setModel(self.slm)
|
|
160
|
+
|
|
161
|
+
if idx == -1:
|
|
162
|
+
idx = len(self.slm.stringList()) - 1
|
|
163
|
+
idx = self.slm.index(idx)
|
|
164
|
+
self.LabelListView.setCurrentIndex(idx)
|
|
165
|
+
|
|
166
|
+
def keyPressEvent(self, event):
|
|
167
|
+
"""Number keys select a label directly."""
|
|
168
|
+
if Qt.Key.Key_0 <= event.key() <= Qt.Key.Key_9:
|
|
169
|
+
if int(event.text()) <= len(self.slm.stringList()):
|
|
170
|
+
idx = self.slm.index(int(event.text()) - 1)
|
|
171
|
+
self.LabelListView.setCurrentIndex(idx)
|
|
172
|
+
else:
|
|
173
|
+
QWidget.keyPressEvent(self, event)
|
|
174
|
+
|
|
175
|
+
def submit_label(self):
|
|
176
|
+
"""Triggered by the OK button."""
|
|
177
|
+
if self._type == 0:
|
|
178
|
+
self.label_name = self.marker_label[
|
|
179
|
+
self.LabelListView.selectedIndexes()[0].row()]
|
|
180
|
+
else:
|
|
181
|
+
self.label_name = self.start_end_label[
|
|
182
|
+
self.LabelListView.selectedIndexes()[0].row()]
|
|
183
|
+
self.hide()
|
|
184
|
+
|
|
185
|
+
def update_label_list(self):
|
|
186
|
+
"""Update the label list when edited."""
|
|
187
|
+
if not self.add_or_delete:
|
|
188
|
+
string_list = self.slm.stringList()
|
|
189
|
+
if self._type == 0:
|
|
190
|
+
self.marker_label = string_list
|
|
191
|
+
if self._type == 1:
|
|
192
|
+
self.start_end_label = string_list
|
|
193
|
+
else:
|
|
194
|
+
self.show_contents(idx=-1)
|
|
195
|
+
self.add_or_delete = False
|
|
196
|
+
self.save_config()
|
|
197
|
+
|
|
198
|
+
def add_label(self):
|
|
199
|
+
if self._type == 0:
|
|
200
|
+
self.marker_label.append("label")
|
|
201
|
+
elif self._type == 1:
|
|
202
|
+
self.start_end_label.append("start end label")
|
|
203
|
+
self.add_or_delete = True
|
|
204
|
+
self.update_label_list()
|
|
205
|
+
|
|
206
|
+
def delete_label(self):
|
|
207
|
+
if not self.LabelListView.selectedIndexes():
|
|
208
|
+
return
|
|
209
|
+
if len(self.slm.stringList()) == 1:
|
|
210
|
+
QMessageBox.about(self, "Error", "You can't delete all labels!")
|
|
211
|
+
return
|
|
212
|
+
if self._type == 0:
|
|
213
|
+
self.marker_label.pop(self.LabelListView.selectedIndexes()[0].row())
|
|
214
|
+
elif self._type == 1:
|
|
215
|
+
self.start_end_label.pop(self.LabelListView.selectedIndexes()[0].row())
|
|
216
|
+
self.add_or_delete = True
|
|
217
|
+
self.update_label_list()
|
|
218
|
+
|
|
219
|
+
def save_config(self):
|
|
220
|
+
"""Persist the (possibly edited) label lists to the user config."""
|
|
221
|
+
self.config.set("gui", "MARKER", str(self.marker_label))
|
|
222
|
+
self.config.set("gui", "STARTEND", str(self.start_end_label))
|
|
223
|
+
save_thread = SaveThread(file=self.config)
|
|
224
|
+
save_thread.save_config()
|
|
225
|
+
|
|
226
|
+
def cancel_event(self):
|
|
227
|
+
"""Triggered by the Cancel button."""
|
|
228
|
+
self.closed = True
|
|
229
|
+
self.hide()
|
|
230
|
+
|
|
231
|
+
def closeEvent(self, event):
|
|
232
|
+
event.ignore()
|
|
233
|
+
self.closed = True
|
|
234
|
+
self.hide()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TransferResultDialog(QDialog, Ui_TransferResultDialog):
|
|
238
|
+
"""Dialog for exporting the annotation to Excel statistics."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, parent=None):
|
|
241
|
+
super().__init__(parent)
|
|
242
|
+
self.setupUi(self)
|
|
243
|
+
|
|
244
|
+
self.ACTimeEditor.setDisabled(True)
|
|
245
|
+
self.TransferStartTimeEdit.setDisabled(True)
|
|
246
|
+
self.ResetTimeCheckBox.clicked.connect(self.ac_time_editor_changed)
|
|
247
|
+
self.ResetTransferStartTimeCheckBox.clicked.connect(self.start_time_editor_changed)
|
|
248
|
+
self.OKBt.clicked.connect(self.ok_event)
|
|
249
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
250
|
+
self.closed = True
|
|
251
|
+
|
|
252
|
+
def ac_time_editor_changed(self):
|
|
253
|
+
self.ACTimeEditor.setEnabled(self.ResetTimeCheckBox.isChecked())
|
|
254
|
+
|
|
255
|
+
def start_time_editor_changed(self):
|
|
256
|
+
self.TransferStartTimeEdit.setEnabled(self.ResetTransferStartTimeCheckBox.isChecked())
|
|
257
|
+
|
|
258
|
+
def transfer(self, config, mianno, ac_time):
|
|
259
|
+
"""Transfer the annotation into analysis dataframes and save to Excel."""
|
|
260
|
+
import pandas as pd
|
|
261
|
+
|
|
262
|
+
mianno = deepcopy(mianno)
|
|
263
|
+
ac_time = deepcopy(ac_time)
|
|
264
|
+
|
|
265
|
+
if self.ResetTimeCheckBox.isChecked():
|
|
266
|
+
ac_time = self.ACTimeEditor.dateTime().toPython()
|
|
267
|
+
else:
|
|
268
|
+
ac_time = datetime.datetime.strptime(ac_time, "%Y%m%d-%H:%M:%S")
|
|
269
|
+
|
|
270
|
+
if self.ResetTransferStartTimeCheckBox.isChecked():
|
|
271
|
+
start_time = self.TransferStartTimeEdit.dateTime().toPython()
|
|
272
|
+
if start_time > ac_time:
|
|
273
|
+
delay_seconds = (start_time - ac_time).seconds
|
|
274
|
+
mianno._marker = mianno.marker[delay_seconds:]
|
|
275
|
+
mianno._start_end = mianno.start_end[delay_seconds:]
|
|
276
|
+
mianno._sleep_state = mianno.sleep_state[delay_seconds:]
|
|
277
|
+
ac_time = start_time
|
|
278
|
+
|
|
279
|
+
df, analyse_df, start_end_df, marker_df = transfer_result(mianno=mianno, ac_time=ac_time)
|
|
280
|
+
|
|
281
|
+
fd, _ = QFileDialog.getSaveFileName(
|
|
282
|
+
self, "Save transfered result",
|
|
283
|
+
f"{get_base_path(config['gui']['openpath'])}", "*.xlsx;;")
|
|
284
|
+
if fd == "":
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
with pd.ExcelWriter(
|
|
289
|
+
fd, datetime_format="yyyy-mm-dd hh:mm:ss") as writer:
|
|
290
|
+
pd.concat([df, analyse_df], axis=1).to_excel(
|
|
291
|
+
excel_writer=writer, sheet_name="Sleep state", index=False)
|
|
292
|
+
start_end_df.to_excel(
|
|
293
|
+
excel_writer=writer, sheet_name="Start End", index=False)
|
|
294
|
+
marker_df.to_excel(
|
|
295
|
+
excel_writer=writer, sheet_name="Marker", index=False)
|
|
296
|
+
except PermissionError as e:
|
|
297
|
+
logger.error(f"Permission error: {e}")
|
|
298
|
+
QMessageBox.about(self, "Error", "Close the EXCEL sheet first.")
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
def ok_event(self):
|
|
304
|
+
self.closed = False
|
|
305
|
+
self.accept()
|
|
306
|
+
|
|
307
|
+
def cancel_event(self):
|
|
308
|
+
self.closed = True
|
|
309
|
+
self.reject()
|
|
310
|
+
|
|
311
|
+
def closeEvent(self, event):
|
|
312
|
+
self.closed = True
|
|
313
|
+
super().closeEvent(event)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
|
|
317
|
+
"""Per-state spectral analysis dialog."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, parent=None, config=None):
|
|
320
|
+
super().__init__(parent)
|
|
321
|
+
self.setupUi(self)
|
|
322
|
+
|
|
323
|
+
self.BPFilterCheckBox.clicked.connect(self.BP_filter_check_changed)
|
|
324
|
+
self.BPFilterCheckBox.setChecked(True)
|
|
325
|
+
self.RejectArtifactCheckBox.setChecked(False)
|
|
326
|
+
self.ArtThresholdSpinBox.setDisabled(True)
|
|
327
|
+
self.RejectArtifactCheckBox.clicked.connect(self.reject_artifact_changed)
|
|
328
|
+
self.StartTimeEditor.setDisabled(True)
|
|
329
|
+
self.EndTimeEditor.setDisabled(True)
|
|
330
|
+
self.StartTimeCheckBox.clicked.connect(self.start_time_editor_changed)
|
|
331
|
+
self.EndTimeCheckBox.clicked.connect(self.end_time_editor_changed)
|
|
332
|
+
self.GaussianCheckBox.clicked.connect(self.gaussian_check_changed)
|
|
333
|
+
self.GaussianCheckBox.setChecked(False)
|
|
334
|
+
self.GaussianSpinBox.setDisabled(True)
|
|
335
|
+
self.GaussianSpinBox.setValue(float(config["spec"]["gaussian_sigma"]))
|
|
336
|
+
self.WinLengthCheckBox.clicked.connect(self.win_length_check_changed)
|
|
337
|
+
self.WinLengthCheckBox.setChecked(False)
|
|
338
|
+
self.WinLengthSpinBox.setDisabled(True)
|
|
339
|
+
self.WinLengthSpinBox.setValue(float(config["spec"]["win_length_sec"]))
|
|
340
|
+
self.nfftCheckBox.clicked.connect(self.nfft_check_changed)
|
|
341
|
+
self.nfftCheckBox.setChecked(False)
|
|
342
|
+
self.nfftSpinBox.setDisabled(True)
|
|
343
|
+
self.nfftSpinBox.setValue(int(float(config["spec"]["nfft_sec"])))
|
|
344
|
+
|
|
345
|
+
self.OKBt.clicked.connect(self.ok_event)
|
|
346
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
347
|
+
self.closed = True
|
|
348
|
+
|
|
349
|
+
def start_time_editor_changed(self):
|
|
350
|
+
self.StartTimeEditor.setEnabled(self.StartTimeCheckBox.isChecked())
|
|
351
|
+
|
|
352
|
+
def end_time_editor_changed(self):
|
|
353
|
+
self.EndTimeEditor.setEnabled(self.EndTimeCheckBox.isChecked())
|
|
354
|
+
|
|
355
|
+
def BP_filter_check_changed(self):
|
|
356
|
+
enabled = self.BPFilterCheckBox.isChecked()
|
|
357
|
+
self.BPLow.setEnabled(enabled)
|
|
358
|
+
self.BPHigh.setEnabled(enabled)
|
|
359
|
+
|
|
360
|
+
def reject_artifact_changed(self):
|
|
361
|
+
self.ArtThresholdSpinBox.setEnabled(self.RejectArtifactCheckBox.isChecked())
|
|
362
|
+
|
|
363
|
+
def gaussian_check_changed(self):
|
|
364
|
+
self.GaussianSpinBox.setEnabled(self.GaussianCheckBox.isChecked())
|
|
365
|
+
|
|
366
|
+
def win_length_check_changed(self):
|
|
367
|
+
self.WinLengthSpinBox.setEnabled(self.WinLengthCheckBox.isChecked())
|
|
368
|
+
|
|
369
|
+
def nfft_check_changed(self):
|
|
370
|
+
self.nfftSpinBox.setEnabled(self.nfftCheckBox.isChecked())
|
|
371
|
+
|
|
372
|
+
def dialog_show(self, channels):
|
|
373
|
+
"""Fill the channel selector."""
|
|
374
|
+
self.ChannelSelector.clear()
|
|
375
|
+
self.ChannelSelector.addItems(channels)
|
|
376
|
+
self.ChannelSelector.setCurrentIndex(0)
|
|
377
|
+
|
|
378
|
+
def spectral_analysis(self, midata, mianno, config):
|
|
379
|
+
"""Run the state spectral analysis and export results."""
|
|
380
|
+
import pandas as pd
|
|
381
|
+
from misleep.preprocessing.artifacts import reject_artifact
|
|
382
|
+
from misleep.preprocessing.filtering import signal_filter
|
|
383
|
+
|
|
384
|
+
mianno = deepcopy(mianno)
|
|
385
|
+
midata = deepcopy(midata)
|
|
386
|
+
|
|
387
|
+
ac_time = datetime.datetime.strptime(midata.time, "%Y%m%d-%H:%M:%S")
|
|
388
|
+
start_sec = 0
|
|
389
|
+
end_sec = mianno.anno_length
|
|
390
|
+
|
|
391
|
+
if self.StartTimeCheckBox.isChecked():
|
|
392
|
+
start_time = self.StartTimeEditor.dateTime().toPython()
|
|
393
|
+
start_sec = int(datetime.timedelta.total_seconds(start_time - ac_time))
|
|
394
|
+
if self.EndTimeCheckBox.isChecked():
|
|
395
|
+
end_time = self.EndTimeEditor.dateTime().toPython()
|
|
396
|
+
end_sec = int(datetime.timedelta.total_seconds(end_time - ac_time))
|
|
397
|
+
|
|
398
|
+
if end_sec <= start_sec:
|
|
399
|
+
start_sec = 0
|
|
400
|
+
end_sec = mianno.anno_length
|
|
401
|
+
|
|
402
|
+
midata = midata.crop([start_sec, end_sec])
|
|
403
|
+
sleep_state = mianno.sleep_state[start_sec:end_sec + 1]
|
|
404
|
+
|
|
405
|
+
channel_idx = self.ChannelSelector.currentIndex()
|
|
406
|
+
channel_data = midata.signals[channel_idx]
|
|
407
|
+
sleep_state = lst2group([[idx, each] for idx, each in enumerate(sleep_state)])
|
|
408
|
+
sf = midata.sf[channel_idx]
|
|
409
|
+
|
|
410
|
+
# Band-pass filter if checked
|
|
411
|
+
freq_band = [self.BPLow.value(), self.BPHigh.value()]
|
|
412
|
+
if self.BPFilterCheckBox.isChecked():
|
|
413
|
+
channel_data, _ = signal_filter(channel_data, sf=sf, btype="bandpass",
|
|
414
|
+
low=freq_band[0], high=freq_band[1])
|
|
415
|
+
|
|
416
|
+
win_length = self.WinLengthSpinBox.value() if self.WinLengthCheckBox.isChecked() else 10.0
|
|
417
|
+
nperseg = int(sf * win_length)
|
|
418
|
+
|
|
419
|
+
nfft = int(self.nfftSpinBox.value() * sf) if self.nfftCheckBox.isChecked() else None
|
|
420
|
+
if nfft is not None and nfft < nperseg:
|
|
421
|
+
nfft = None
|
|
422
|
+
|
|
423
|
+
gaussian_sigma = self.GaussianSpinBox.value() if self.GaussianCheckBox.isChecked() else None
|
|
424
|
+
|
|
425
|
+
state_codes = sorted(set(mianno.sleep_state[start_sec:end_sec + 1]))
|
|
426
|
+
state_data = {
|
|
427
|
+
state: np.concatenate([
|
|
428
|
+
channel_data[int(each[0] * sf): int(each[1] * sf)]
|
|
429
|
+
for each in sleep_state if each[2] == state
|
|
430
|
+
])
|
|
431
|
+
for state in state_codes
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
threshold = self.ArtThresholdSpinBox.value() if self.RejectArtifactCheckBox.isChecked() else 1.5
|
|
435
|
+
if self.RejectArtifactCheckBox.isChecked():
|
|
436
|
+
state_data = {
|
|
437
|
+
state: reject_artifact(data, sf=sf, threshold=threshold)
|
|
438
|
+
for state, data in state_data.items()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
relative = self.RelativeCheckBox.isChecked()
|
|
442
|
+
spectra = {
|
|
443
|
+
state: cal_draw_spectrum(data=data, sf=sf, nperseg=nperseg,
|
|
444
|
+
freq_band=freq_band, relative=relative,
|
|
445
|
+
nfft=nfft, gaussian_sigma=gaussian_sigma)
|
|
446
|
+
for state, data in state_data.items()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
name_map = mianno.state_map
|
|
450
|
+
|
|
451
|
+
# Optional per-hour spectral segmentation
|
|
452
|
+
hour_spec = {state: [] for state in spectra}
|
|
453
|
+
if self.HourSegmentCheckBox.isChecked():
|
|
454
|
+
for sec in range(0, end_sec - start_sec, 3600):
|
|
455
|
+
hour_states = mianno.sleep_state[start_sec + sec:start_sec + sec + 3600]
|
|
456
|
+
hour_states = lst2group([[idx + sec, each]
|
|
457
|
+
for idx, each in enumerate(hour_states)])
|
|
458
|
+
for state in spectra:
|
|
459
|
+
data_parts = [channel_data[int(each[0] * sf): int(each[1] * sf)]
|
|
460
|
+
for each in hour_states if each[2] == state]
|
|
461
|
+
data = np.concatenate(data_parts) if data_parts else np.array([])
|
|
462
|
+
if self.RejectArtifactCheckBox.isChecked() and len(data):
|
|
463
|
+
data = reject_artifact(data, sf=sf, threshold=threshold)
|
|
464
|
+
if len(data) > sf * 10:
|
|
465
|
+
hour_spec[state].append(cal_draw_spectrum(
|
|
466
|
+
data=data, sf=sf, nperseg=nperseg,
|
|
467
|
+
freq_band=freq_band, relative=relative, nfft=nfft,
|
|
468
|
+
gaussian_sigma=gaussian_sigma)[0][1])
|
|
469
|
+
|
|
470
|
+
fd = QFileDialog.getExistingDirectory(self, "Select a folder to save states' data",
|
|
471
|
+
f"{config['gui']['openpath']}")
|
|
472
|
+
if fd == "":
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
figures = [figure for _, figure in spectra.values()]
|
|
476
|
+
try:
|
|
477
|
+
output = fd + \
|
|
478
|
+
f"/{os.path.basename(config['gui']['openpath']).split('.')[0]}_power_results.xlsx"
|
|
479
|
+
with pd.ExcelWriter(output) as writer:
|
|
480
|
+
for state, (spec, figure) in spectra.items():
|
|
481
|
+
state_name = str(name_map.get(state, state))
|
|
482
|
+
safe_name = "".join(
|
|
483
|
+
c if c not in '\\/:*?"<>|' else "_"
|
|
484
|
+
for c in state_name)
|
|
485
|
+
figure.savefig(fd + "/" + safe_name + "_spectrum.pdf")
|
|
486
|
+
_df = pd.DataFrame(
|
|
487
|
+
data=spec.T, columns=["frequency", "power"])
|
|
488
|
+
if hour_spec[state]:
|
|
489
|
+
_df[[str(each) for each in range(
|
|
490
|
+
1, len(hour_spec[state]) + 1)]] = \
|
|
491
|
+
pd.DataFrame(hour_spec[state]).T
|
|
492
|
+
_df.to_excel(
|
|
493
|
+
excel_writer=writer, sheet_name=safe_name[:31],
|
|
494
|
+
index=False)
|
|
495
|
+
except PermissionError as e:
|
|
496
|
+
logger.error(f"Permission error: {e}")
|
|
497
|
+
QMessageBox.about(self, "Error", "Close the PDF or EXCEL file under this folder first.")
|
|
498
|
+
return False
|
|
499
|
+
finally:
|
|
500
|
+
# The figures from cal_draw_spectrum are non-pyplot Figure
|
|
501
|
+
# objects; clear() releases their memory without touching the
|
|
502
|
+
# main window's figures.
|
|
503
|
+
for figure in figures:
|
|
504
|
+
try:
|
|
505
|
+
figure.clear()
|
|
506
|
+
except Exception: # pragma: no cover
|
|
507
|
+
pass
|
|
508
|
+
return True
|
|
509
|
+
|
|
510
|
+
def ok_event(self):
|
|
511
|
+
self.closed = False
|
|
512
|
+
self.accept()
|
|
513
|
+
|
|
514
|
+
def cancel_event(self):
|
|
515
|
+
self.closed = True
|
|
516
|
+
self.reject()
|
|
517
|
+
|
|
518
|
+
def closeEvent(self, event):
|
|
519
|
+
self.closed = True
|
|
520
|
+
super().closeEvent(event)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class HorizontalLineDialog(QDialog, Ui_horizontal_line_dialog):
|
|
524
|
+
"""Dialog for adding horizontal reference lines to the signal plot."""
|
|
525
|
+
|
|
526
|
+
def __init__(self, parent=None):
|
|
527
|
+
super().__init__(parent)
|
|
528
|
+
self.setupUi(self)
|
|
529
|
+
|
|
530
|
+
self.color = "#ff0000"
|
|
531
|
+
self.SetColorBt.setText(self.color)
|
|
532
|
+
self._style_color_bt()
|
|
533
|
+
self.SetColorBt.clicked.connect(self.select_color)
|
|
534
|
+
|
|
535
|
+
self.AddLineBt.clicked.connect(self.add_line)
|
|
536
|
+
self.DeleteLineBt.clicked.connect(self.delete_line)
|
|
537
|
+
self.OKBt.clicked.connect(self.ok_event)
|
|
538
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
539
|
+
|
|
540
|
+
self.relative_methods_dict = {0: "Standard deviation", 1: "Mean"}
|
|
541
|
+
# the .ui ships with empty combo items; fill them here
|
|
542
|
+
self.RelativeCalComboBox.clear()
|
|
543
|
+
self.RelativeCalComboBox.addItems(list(self.relative_methods_dict.values()))
|
|
544
|
+
self.RelativeNumEditor.setToolTip(
|
|
545
|
+
"Line position = this number x SD (or x Mean) of the channel")
|
|
546
|
+
|
|
547
|
+
self.line_slm = QStringListModel()
|
|
548
|
+
self.current_channel = None
|
|
549
|
+
self.horizontal_line = None
|
|
550
|
+
|
|
551
|
+
self.UseRelativeCheckBox.clicked.connect(self.click_relative_checkbox)
|
|
552
|
+
self.RelativeCalComboBox.setDisabled(True)
|
|
553
|
+
self.RelativeNumEditor.setEnabled(False)
|
|
554
|
+
self.ChannelComboBox.currentIndexChanged.connect(self.ch_combo_change)
|
|
555
|
+
|
|
556
|
+
self.closed = True
|
|
557
|
+
self.midata = None # For SD/Mean calculation
|
|
558
|
+
|
|
559
|
+
def click_relative_checkbox(self):
|
|
560
|
+
checked = self.UseRelativeCheckBox.isChecked()
|
|
561
|
+
self.RelativeCalComboBox.setDisabled(not checked)
|
|
562
|
+
self.RelativeNumEditor.setEnabled(checked)
|
|
563
|
+
self.SelfDefineValueEditor.setDisabled(checked)
|
|
564
|
+
|
|
565
|
+
def show_chs(self):
|
|
566
|
+
"""Initialize the channel combobox."""
|
|
567
|
+
self.ChannelComboBox.clear()
|
|
568
|
+
self.ChannelComboBox.addItems(list(self.horizontal_line.keys()))
|
|
569
|
+
self.ChannelComboBox.setCurrentIndex(0)
|
|
570
|
+
self.current_channel = list(self.horizontal_line.keys())[0]
|
|
571
|
+
self.show_lines()
|
|
572
|
+
|
|
573
|
+
def ch_combo_change(self):
|
|
574
|
+
current_channel_idx = self.ChannelComboBox.currentIndex()
|
|
575
|
+
self.current_channel = list(self.horizontal_line.keys())[current_channel_idx]
|
|
576
|
+
self.show_lines()
|
|
577
|
+
|
|
578
|
+
def show_lines(self):
|
|
579
|
+
strs = [f"{each[0]}_{each[1]}_{each[2]}"
|
|
580
|
+
for each in self.horizontal_line[self.current_channel]]
|
|
581
|
+
self.line_slm.setStringList(strs)
|
|
582
|
+
self.LineListView.setModel(self.line_slm)
|
|
583
|
+
|
|
584
|
+
def add_line(self):
|
|
585
|
+
if self.UseRelativeCheckBox.isChecked():
|
|
586
|
+
method_idx = self.RelativeCalComboBox.currentIndex()
|
|
587
|
+
multiplier = self.RelativeNumEditor.value()
|
|
588
|
+
if method_idx == 0:
|
|
589
|
+
value = multiplier * np.std(self.midata.signals[self.midata.channels.index(self.current_channel)])
|
|
590
|
+
comment = f"{multiplier:g} x SD"
|
|
591
|
+
elif method_idx == 1:
|
|
592
|
+
value = multiplier * np.mean(self.midata.signals[self.midata.channels.index(self.current_channel)])
|
|
593
|
+
comment = f"{multiplier:g} x Mean"
|
|
594
|
+
else:
|
|
595
|
+
value = 0
|
|
596
|
+
comment = "self defined"
|
|
597
|
+
else:
|
|
598
|
+
value = self.SelfDefineValueEditor.value()
|
|
599
|
+
comment = "self defined"
|
|
600
|
+
|
|
601
|
+
self.horizontal_line[self.current_channel].append([value, self.color, comment])
|
|
602
|
+
self.show_lines()
|
|
603
|
+
|
|
604
|
+
def delete_line(self):
|
|
605
|
+
selected_line = [each.row() for each in self.LineListView.selectedIndexes()]
|
|
606
|
+
if len(selected_line) == 0:
|
|
607
|
+
return
|
|
608
|
+
self.horizontal_line[self.current_channel].pop(selected_line[0])
|
|
609
|
+
self.show_lines()
|
|
610
|
+
|
|
611
|
+
def select_color(self):
|
|
612
|
+
c = QColorDialog.getColor(initial=QColor(255, 0, 0))
|
|
613
|
+
self.color = c.name()
|
|
614
|
+
if self.color == "#000000":
|
|
615
|
+
self.color = "#ff0000"
|
|
616
|
+
self.SetColorBt.setText(self.color)
|
|
617
|
+
self._style_color_bt()
|
|
618
|
+
|
|
619
|
+
def _style_color_bt(self):
|
|
620
|
+
"""Style the swatch with readable text on any theme/color."""
|
|
621
|
+
c = QColor(self.color)
|
|
622
|
+
lum = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()
|
|
623
|
+
fg = "#000000" if lum > 150 else "#ffffff"
|
|
624
|
+
self.SetColorBt.setStyleSheet(
|
|
625
|
+
f"QPushButton {{ background-color: {self.color}; color: {fg};"
|
|
626
|
+
f" border: 1px solid {self.color}; border-radius: 0px;"
|
|
627
|
+
f" font-weight: 600; padding: 2px 8px; min-height: 24px; }}")
|
|
628
|
+
|
|
629
|
+
def ok_event(self):
|
|
630
|
+
self.closed = False
|
|
631
|
+
self.hide()
|
|
632
|
+
|
|
633
|
+
def cancel_event(self):
|
|
634
|
+
self.closed = True
|
|
635
|
+
self.hide()
|
|
636
|
+
|
|
637
|
+
def closeEvent(self, event):
|
|
638
|
+
event.ignore()
|
|
639
|
+
self.closed = True
|
|
640
|
+
self.hide()
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class SWADetectionDialog(QDialog, Ui_SWADetectDialog):
|
|
644
|
+
"""Slow-wave activity detection dialog."""
|
|
645
|
+
|
|
646
|
+
def __init__(self, parent=None):
|
|
647
|
+
super().__init__(parent)
|
|
648
|
+
self.setupUi(self)
|
|
649
|
+
|
|
650
|
+
self.unit_map = {0: 1, 1: 1000, 2: 1000000}
|
|
651
|
+
self.OKBt.clicked.connect(self.ok_event)
|
|
652
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
653
|
+
|
|
654
|
+
def show_chs(self, channels):
|
|
655
|
+
self.ChannelComBox.clear()
|
|
656
|
+
self.ChannelComBox.addItems(channels)
|
|
657
|
+
self.ChannelComBox.setCurrentIndex(0)
|
|
658
|
+
|
|
659
|
+
def swa_detection(self, midata, mianno, config):
|
|
660
|
+
"""Run SWA detection on the selected channel and states."""
|
|
661
|
+
import pandas as pd
|
|
662
|
+
from misleep.analysis.detection import SWA_detection
|
|
663
|
+
|
|
664
|
+
freq_low = self.FreqLowEditor.value()
|
|
665
|
+
freq_high = self.FreqHighEditor.value()
|
|
666
|
+
channel_idx = self.ChannelComBox.currentIndex()
|
|
667
|
+
channel = midata.channels[channel_idx]
|
|
668
|
+
signal_data = deepcopy(midata.pick_chs([channel]))
|
|
669
|
+
signal_sf = signal_data.sf[0]
|
|
670
|
+
signal_data = signal_data.signals[0]
|
|
671
|
+
|
|
672
|
+
std_thresh = self.StdEditor.value()
|
|
673
|
+
|
|
674
|
+
sleep_state = lst2group([[idx, each] for idx, each in enumerate(deepcopy(mianno.sleep_state))])
|
|
675
|
+
swa_lst = []
|
|
676
|
+
for state, state_name in [(1, "NREM"), (2, "REM"), (3, "Wake"), (4, "Init")]:
|
|
677
|
+
checkbox = {1: self.NREMCheckbox, 2: self.REMCheckbox,
|
|
678
|
+
3: self.WakeCheckbox, 4: self.InitCheckbox}[state]
|
|
679
|
+
if not checkbox.isChecked():
|
|
680
|
+
continue
|
|
681
|
+
amp_threshold_low, amp_threshold_high = self.get_state_thres(
|
|
682
|
+
data=signal_data, sf=signal_sf, sleep_state=sleep_state,
|
|
683
|
+
state=state, thres=std_thresh)
|
|
684
|
+
for each in sleep_state:
|
|
685
|
+
if each[2] == state and each[1] - each[0] > 5:
|
|
686
|
+
data_ = signal_data[int(each[0] * signal_sf): int(each[1] * signal_sf)]
|
|
687
|
+
swa_lst_ = SWA_detection(
|
|
688
|
+
data_, signal_sf, freq_band=[freq_low, freq_high],
|
|
689
|
+
amp_threshold=(amp_threshold_low, amp_threshold_high),
|
|
690
|
+
start_time_sec=each[0])
|
|
691
|
+
if swa_lst_ is None:
|
|
692
|
+
continue
|
|
693
|
+
for each in swa_lst_:
|
|
694
|
+
each.append(state_name)
|
|
695
|
+
swa_lst.append(each)
|
|
696
|
+
|
|
697
|
+
if self.ExportCheckbox.isChecked():
|
|
698
|
+
df = pd.DataFrame(swa_lst, columns=["StartTime", "NegTime", "MiddleTime",
|
|
699
|
+
"PosTime", "EndTime", "Duration", "NegPeak",
|
|
700
|
+
"PosPeak", "PTP", "Slope", "Frequency", "State"])
|
|
701
|
+
fd, _ = QFileDialog.getSaveFileName(self, "Save SWA detection result",
|
|
702
|
+
f"{config['gui']['openpath']}SWA_result.csv", "*.csv;;")
|
|
703
|
+
if fd == "":
|
|
704
|
+
return
|
|
705
|
+
try:
|
|
706
|
+
df.to_csv(fd, index=False)
|
|
707
|
+
except PermissionError as e:
|
|
708
|
+
logger.error(f"Permission error: {e}")
|
|
709
|
+
QMessageBox.critical(self, "Error", f"Permission denied: {e}, close the file first")
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
logger.info(f"SWA_detection: Freq_thres: {[freq_low, freq_high]}, std_thresh: {std_thresh}")
|
|
713
|
+
return swa_lst
|
|
714
|
+
|
|
715
|
+
def get_state_thres(self, data, sf, sleep_state, state, thres):
|
|
716
|
+
"""Compute amplitude thresholds from the full state data."""
|
|
717
|
+
all_data = [data[int(each[0] * sf): int(each[1] * sf)]
|
|
718
|
+
for each in sleep_state if each[2] == state]
|
|
719
|
+
all_data = [item for each in all_data for item in each]
|
|
720
|
+
mean_ = np.mean(all_data)
|
|
721
|
+
std_ = np.std(all_data)
|
|
722
|
+
return thres * std_ + mean_, 10 * std_ + mean_
|
|
723
|
+
|
|
724
|
+
def ok_event(self):
|
|
725
|
+
self.closed = False
|
|
726
|
+
self.hide()
|
|
727
|
+
|
|
728
|
+
def cancel_event(self):
|
|
729
|
+
self.closed = True
|
|
730
|
+
self.hide()
|
|
731
|
+
|
|
732
|
+
def closeEvent(self, event):
|
|
733
|
+
event.ignore()
|
|
734
|
+
self.closed = True
|
|
735
|
+
self.hide()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
class SpindleDetectionDialog(QDialog, Ui_SpindleDetectDialog):
|
|
739
|
+
"""Sleep spindle detection dialog."""
|
|
740
|
+
|
|
741
|
+
def __init__(self, parent=None):
|
|
742
|
+
super().__init__(parent)
|
|
743
|
+
self.setupUi(self)
|
|
744
|
+
self.OKBt.clicked.connect(self.ok_event)
|
|
745
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
746
|
+
|
|
747
|
+
def show_chs(self, channels):
|
|
748
|
+
self.ChannelComBox.clear()
|
|
749
|
+
self.ChannelComBox.addItems(channels)
|
|
750
|
+
self.ChannelComBox.setCurrentIndex(0)
|
|
751
|
+
|
|
752
|
+
def spindle_detection(self, midata, mianno, config):
|
|
753
|
+
"""Run spindle detection on the selected channel and states."""
|
|
754
|
+
import pandas as pd
|
|
755
|
+
from misleep.analysis.detection import spindle_detection
|
|
756
|
+
|
|
757
|
+
freq_low = self.FreqLowEditor.value()
|
|
758
|
+
freq_high = self.FreqHighEditor.value()
|
|
759
|
+
channel_idx = self.ChannelComBox.currentIndex()
|
|
760
|
+
channel = midata.channels[channel_idx]
|
|
761
|
+
signal_data = deepcopy(midata.pick_chs([channel]))
|
|
762
|
+
signal_sf = signal_data.sf[0]
|
|
763
|
+
signal_data = signal_data.signals[0]
|
|
764
|
+
|
|
765
|
+
std_thres_input = self.StdEditor.value()
|
|
766
|
+
duration_thres_input = self.durationThresholdEditor.value()
|
|
767
|
+
|
|
768
|
+
sleep_state = lst2group([[idx, each] for idx, each in enumerate(deepcopy(mianno.sleep_state))])
|
|
769
|
+
spindle_lst = []
|
|
770
|
+
for state, state_name in [(1, "NREM"), (2, "REM"), (3, "Wake"), (4, "Init")]:
|
|
771
|
+
checkbox = {1: self.NREMCheckbox, 2: self.REMCheckbox,
|
|
772
|
+
3: self.WakeCheckbox, 4: self.InitCheckbox}[state]
|
|
773
|
+
if not checkbox.isChecked():
|
|
774
|
+
continue
|
|
775
|
+
std_thres, duration_thres = self.get_state_thres(
|
|
776
|
+
data=signal_data, sf=signal_sf, sleep_state=sleep_state, state=state,
|
|
777
|
+
thres1=std_thres_input, thres2=duration_thres_input)
|
|
778
|
+
for each in sleep_state:
|
|
779
|
+
if each[2] == state and each[1] - each[0] > 5:
|
|
780
|
+
data_ = signal_data[int(each[0] * signal_sf): int(each[1] * signal_sf)]
|
|
781
|
+
spindle_lst_ = spindle_detection(
|
|
782
|
+
data_, signal_sf, freq_band=[freq_low, freq_high],
|
|
783
|
+
std_thresh=std_thres, duration_thresh=duration_thres,
|
|
784
|
+
start_time_sec=each[0])
|
|
785
|
+
if spindle_lst_ is None:
|
|
786
|
+
continue
|
|
787
|
+
for each in spindle_lst_:
|
|
788
|
+
each.append(state_name)
|
|
789
|
+
spindle_lst.append(each)
|
|
790
|
+
|
|
791
|
+
if self.ExportCheckbox.isChecked():
|
|
792
|
+
df = pd.DataFrame(spindle_lst, columns=["StartTime", "EndTime", "State"])
|
|
793
|
+
fd, _ = QFileDialog.getSaveFileName(self, "Save spindle detection result",
|
|
794
|
+
f"{config['gui']['openpath']}spindle_result.csv", "*.csv;;")
|
|
795
|
+
if fd == "":
|
|
796
|
+
return
|
|
797
|
+
try:
|
|
798
|
+
df.to_csv(fd, index=False)
|
|
799
|
+
except PermissionError as e:
|
|
800
|
+
logger.error(f"Permission denied: {e}")
|
|
801
|
+
QMessageBox.critical(self, "Error", f"Permission denied: {e}, close the file first")
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
logger.info(f"Spindle_detection: Freq_thres: {[freq_low, freq_high]}, "
|
|
805
|
+
f"std_thresh_input: {std_thres_input}, "
|
|
806
|
+
f"duration_thresh_input: {duration_thres_input}")
|
|
807
|
+
return spindle_lst
|
|
808
|
+
|
|
809
|
+
def get_state_thres(self, data, sf, sleep_state, state, thres1, thres2):
|
|
810
|
+
"""Compute thresholds from the full state data."""
|
|
811
|
+
all_data = [data[int(each[0] * sf): int(each[1] * sf)]
|
|
812
|
+
for each in sleep_state if each[2] == state]
|
|
813
|
+
all_data = [item for each in all_data for item in each]
|
|
814
|
+
mean_ = np.mean(all_data)
|
|
815
|
+
std_ = np.std(all_data)
|
|
816
|
+
return thres1 * std_ + mean_, thres2 * std_ + mean_
|
|
817
|
+
|
|
818
|
+
def ok_event(self):
|
|
819
|
+
self.closed = False
|
|
820
|
+
self.hide()
|
|
821
|
+
|
|
822
|
+
def cancel_event(self):
|
|
823
|
+
self.closed = True
|
|
824
|
+
self.hide()
|
|
825
|
+
|
|
826
|
+
def closeEvent(self, event):
|
|
827
|
+
event.ignore()
|
|
828
|
+
self.closed = True
|
|
829
|
+
self.hide()
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
class AutoStageLightGBMDialog(QDialog, Ui_AutoStageLightGBMDialog):
|
|
833
|
+
"""LightGBM auto-staging dialog."""
|
|
834
|
+
|
|
835
|
+
def __init__(self, parent=None):
|
|
836
|
+
super().__init__(parent)
|
|
837
|
+
self.setupUi(self)
|
|
838
|
+
self.OKBt.clicked.connect(self.ok_event)
|
|
839
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
840
|
+
|
|
841
|
+
def show_chs(self, channels):
|
|
842
|
+
self.EEGChannelCombox.clear()
|
|
843
|
+
self.EEGChannelCombox.addItems(channels)
|
|
844
|
+
self.EEGChannelCombox.setCurrentIndex(0)
|
|
845
|
+
self.EMGchannelCombox.clear()
|
|
846
|
+
self.EMGchannelCombox.addItems(channels)
|
|
847
|
+
self.EMGchannelCombox.setCurrentIndex(1)
|
|
848
|
+
|
|
849
|
+
def auto_stage(self, midata, mianno):
|
|
850
|
+
"""Run LightGBM auto staging on the selected channels."""
|
|
851
|
+
from misleep.analysis.auto_stage import auto_stage_gbm
|
|
852
|
+
|
|
853
|
+
EEG_channel_idx = self.EEGChannelCombox.currentIndex()
|
|
854
|
+
EMG_channel_idx = self.EMGchannelCombox.currentIndex()
|
|
855
|
+
EEG = deepcopy(midata.signals[EEG_channel_idx])
|
|
856
|
+
EMG = deepcopy(midata.signals[EMG_channel_idx])
|
|
857
|
+
label = deepcopy(mianno._sleep_state)
|
|
858
|
+
sf = deepcopy(midata.sf[EEG_channel_idx])
|
|
859
|
+
|
|
860
|
+
EEG_site = ["P", "F"][self.EEGSiteCombox.currentIndex()]
|
|
861
|
+
mouse_age = ["adult", "ado", "P30"][self.AgeCombox.currentIndex()]
|
|
862
|
+
|
|
863
|
+
pred_label = auto_stage_gbm(EEG=EEG, EMG=EMG, label=label, sf=sf,
|
|
864
|
+
EEG_channel=EEG_site, mouse_age=mouse_age)
|
|
865
|
+
save_anno = self.SaveAnnoCheckbox.isChecked()
|
|
866
|
+
return pred_label, save_anno
|
|
867
|
+
|
|
868
|
+
def ok_event(self):
|
|
869
|
+
self.closed = False
|
|
870
|
+
self.hide()
|
|
871
|
+
|
|
872
|
+
def cancel_event(self):
|
|
873
|
+
self.closed = True
|
|
874
|
+
self.hide()
|
|
875
|
+
|
|
876
|
+
def closeEvent(self, event):
|
|
877
|
+
event.ignore()
|
|
878
|
+
self.closed = True
|
|
879
|
+
self.hide()
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
class AutoStageCausalTransformerDialog(QDialog, Ui_AutoStageCausalTransformerDialog):
|
|
883
|
+
"""Causal-transformer auto-staging dialog (requires PyTorch)."""
|
|
884
|
+
|
|
885
|
+
#: Whether the transformer backend is importable.
|
|
886
|
+
available = True
|
|
887
|
+
|
|
888
|
+
def __init__(self, parent=None):
|
|
889
|
+
super().__init__(parent)
|
|
890
|
+
self.setupUi(self)
|
|
891
|
+
self.OKBt.clicked.connect(self.ok_event)
|
|
892
|
+
self.CancelBt.clicked.connect(self.cancel_event)
|
|
893
|
+
|
|
894
|
+
def show_chs(self, channels):
|
|
895
|
+
self.EEGChannelCombox.clear()
|
|
896
|
+
self.EEGChannelCombox.addItems(channels)
|
|
897
|
+
self.EEGChannelCombox.setCurrentIndex(0)
|
|
898
|
+
self.EMGchannelCombox.clear()
|
|
899
|
+
self.EMGchannelCombox.addItems(channels)
|
|
900
|
+
self.EMGchannelCombox.setCurrentIndex(1)
|
|
901
|
+
|
|
902
|
+
def auto_stage(self, midata, mianno):
|
|
903
|
+
"""Run transformer auto staging on the selected channels."""
|
|
904
|
+
try:
|
|
905
|
+
from misleep.analysis.transformer import AutoStageConfig, auto_stage_llm
|
|
906
|
+
except ImportError as e:
|
|
907
|
+
self.available = False
|
|
908
|
+
raise ImportError(
|
|
909
|
+
"The transformer auto-staging requires PyTorch. "
|
|
910
|
+
"Install it with: pip install 'misleep[transformer]'") from e
|
|
911
|
+
|
|
912
|
+
EEG_channel_idx = self.EEGChannelCombox.currentIndex()
|
|
913
|
+
EMG_channel_idx = self.EMGchannelCombox.currentIndex()
|
|
914
|
+
EEG = deepcopy(midata.signals[EEG_channel_idx])
|
|
915
|
+
EMG = deepcopy(midata.signals[EMG_channel_idx])
|
|
916
|
+
label = deepcopy(mianno._sleep_state)
|
|
917
|
+
sf = deepcopy(midata.sf[EEG_channel_idx])
|
|
918
|
+
|
|
919
|
+
config = AutoStageConfig()
|
|
920
|
+
config.sf = sf
|
|
921
|
+
config.label_stride_seconds = 5
|
|
922
|
+
config.output_stride_seconds = 5
|
|
923
|
+
label = downsample_by_most_frequent(label, 5)
|
|
924
|
+
pred_label = auto_stage_llm(EEG=EEG, EMG=EMG, label=label, config=config)
|
|
925
|
+
pred_label = [[each] * 5 for each in pred_label]
|
|
926
|
+
pred_label = [item for each in pred_label for item in each]
|
|
927
|
+
for idx in range(1, len(pred_label) - 1):
|
|
928
|
+
label_ = pred_label[idx]
|
|
929
|
+
if label_ == 4:
|
|
930
|
+
pred_label[idx] = 3
|
|
931
|
+
if label_ == 3 and pred_label[idx + 1] == 2: # REM after Wake
|
|
932
|
+
pred_label[idx + 1] = 1
|
|
933
|
+
if pred_label[idx - 1] == pred_label[idx + 1] and pred_label[idx] != 3:
|
|
934
|
+
pred_label[idx] = pred_label[idx - 1]
|
|
935
|
+
save_anno = self.SaveAnnoCheckbox.isChecked()
|
|
936
|
+
return pred_label, save_anno
|
|
937
|
+
|
|
938
|
+
def ok_event(self):
|
|
939
|
+
self.closed = False
|
|
940
|
+
self.hide()
|
|
941
|
+
|
|
942
|
+
def cancel_event(self):
|
|
943
|
+
self.closed = True
|
|
944
|
+
self.hide()
|
|
945
|
+
|
|
946
|
+
def closeEvent(self, event):
|
|
947
|
+
event.ignore()
|
|
948
|
+
self.closed = True
|
|
949
|
+
self.hide()
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
class SaveDataDialog(QDialog, Ui_SaveDataDialog):
|
|
953
|
+
"""Dialog for exporting (cropped) data."""
|
|
954
|
+
|
|
955
|
+
def __init__(self, parent=None):
|
|
956
|
+
super().__init__(parent)
|
|
957
|
+
self.setupUi(self)
|
|
958
|
+
|
|
959
|
+
self.CropStartTimeEditor.setDisabled(True)
|
|
960
|
+
self.CropEndTimeEditor.setDisabled(True)
|
|
961
|
+
self.CropDataStartCheckBox.clicked.connect(self.crop_start_time_editor_changed)
|
|
962
|
+
self.CropDataEndCheckBox.clicked.connect(self.crop_end_time_editor_changed)
|
|
963
|
+
|
|
964
|
+
self.channel_slm = QStringListModel()
|
|
965
|
+
self.OKBtn.clicked.connect(self.ok_event)
|
|
966
|
+
self.CancelBtn.clicked.connect(self.cancel_event)
|
|
967
|
+
self.closed = True
|
|
968
|
+
|
|
969
|
+
def crop_start_time_editor_changed(self):
|
|
970
|
+
self.CropStartTimeEditor.setEnabled(self.CropDataStartCheckBox.isChecked())
|
|
971
|
+
|
|
972
|
+
def crop_end_time_editor_changed(self):
|
|
973
|
+
self.CropEndTimeEditor.setEnabled(self.CropDataEndCheckBox.isChecked())
|
|
974
|
+
|
|
975
|
+
def fill_midata_params(self, midata):
|
|
976
|
+
"""Fill channel list and crop time range from a MiData."""
|
|
977
|
+
self.channel_slm.setStringList(midata.channels)
|
|
978
|
+
self.ChannelListView.setModel(self.channel_slm)
|
|
979
|
+
|
|
980
|
+
self.CropStartTimeEditor.setDateTime(
|
|
981
|
+
datetime.datetime.strptime(midata.time, "%Y%m%d-%H:%M:%S"))
|
|
982
|
+
end_time = datetime.datetime.strptime(midata.time, "%Y%m%d-%H:%M:%S") + \
|
|
983
|
+
datetime.timedelta(seconds=midata.duration)
|
|
984
|
+
self.CropEndTimeEditor.setDateTime(end_time)
|
|
985
|
+
|
|
986
|
+
def ok_event(self):
|
|
987
|
+
self.closed = False
|
|
988
|
+
self.accept()
|
|
989
|
+
|
|
990
|
+
def cancel_event(self):
|
|
991
|
+
self.closed = True
|
|
992
|
+
self.reject()
|
|
993
|
+
|
|
994
|
+
def closeEvent(self, event):
|
|
995
|
+
self.closed = True
|
|
996
|
+
super().closeEvent(event)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
class EventListDialog(QDialog):
|
|
1000
|
+
"""List the already-labeled marker / start-end events.
|
|
1001
|
+
|
|
1002
|
+
Lets the user see every labeled event at a glance, jump to one with a
|
|
1003
|
+
double-click, and add or delete events.
|
|
1004
|
+
"""
|
|
1005
|
+
|
|
1006
|
+
def __init__(self, parent=None):
|
|
1007
|
+
super().__init__(parent)
|
|
1008
|
+
self.setWindowTitle("Annotation events")
|
|
1009
|
+
self.resize(400, 440)
|
|
1010
|
+
self.setWindowIcon(app_icon())
|
|
1011
|
+
self._kind = "marker"
|
|
1012
|
+
|
|
1013
|
+
layout = QVBoxLayout(self)
|
|
1014
|
+
self.hint = QLabel()
|
|
1015
|
+
self.hint.setWordWrap(True)
|
|
1016
|
+
layout.addWidget(self.hint)
|
|
1017
|
+
|
|
1018
|
+
self.list = QListWidget()
|
|
1019
|
+
layout.addWidget(self.list, 1)
|
|
1020
|
+
self.list.itemDoubleClicked.connect(self._jump)
|
|
1021
|
+
|
|
1022
|
+
btn_row = QHBoxLayout()
|
|
1023
|
+
jump_bt = QPushButton("Jump to")
|
|
1024
|
+
add_bt = QPushButton("Add")
|
|
1025
|
+
del_bt = QPushButton("Delete")
|
|
1026
|
+
close_bt = QPushButton("Close")
|
|
1027
|
+
jump_bt.clicked.connect(self._jump)
|
|
1028
|
+
add_bt.clicked.connect(self._add)
|
|
1029
|
+
del_bt.clicked.connect(self._delete)
|
|
1030
|
+
close_bt.clicked.connect(self.accept)
|
|
1031
|
+
for b in (jump_bt, add_bt, del_bt, close_bt):
|
|
1032
|
+
btn_row.addWidget(b)
|
|
1033
|
+
layout.addLayout(btn_row)
|
|
1034
|
+
|
|
1035
|
+
# ------------------------------------------------------------------
|
|
1036
|
+
def _main(self):
|
|
1037
|
+
return self.parent()
|
|
1038
|
+
|
|
1039
|
+
def show_events(self, kind="marker"):
|
|
1040
|
+
"""Fill the list from the parent's annotation (kind: marker/start_end)."""
|
|
1041
|
+
self._kind = kind
|
|
1042
|
+
self.setWindowTitle("Markers" if kind == "marker" else "Start-End events")
|
|
1043
|
+
self._refresh()
|
|
1044
|
+
|
|
1045
|
+
def _refresh(self):
|
|
1046
|
+
mianno = self._main().mianno
|
|
1047
|
+
self.list.clear()
|
|
1048
|
+
if self._kind == "marker":
|
|
1049
|
+
for each in mianno.marker:
|
|
1050
|
+
self.list.addItem(f"{each[0]:.3f} s — {each[1]}")
|
|
1051
|
+
self.hint.setText(
|
|
1052
|
+
f"{len(mianno.marker)} marker(s) — double-click to jump")
|
|
1053
|
+
else:
|
|
1054
|
+
for each in mianno.start_end:
|
|
1055
|
+
self.list.addItem(f"{each[0]:.3f} — {each[1]:.3f} s — {each[2]}")
|
|
1056
|
+
self.hint.setText(
|
|
1057
|
+
f"{len(mianno.start_end)} start-end event(s) — double-click to jump")
|
|
1058
|
+
|
|
1059
|
+
def _jump(self):
|
|
1060
|
+
row = self.list.currentRow()
|
|
1061
|
+
if row < 0:
|
|
1062
|
+
return
|
|
1063
|
+
mianno = self._main().mianno
|
|
1064
|
+
if self._kind == "marker":
|
|
1065
|
+
t = mianno.marker[row][0]
|
|
1066
|
+
else:
|
|
1067
|
+
t = mianno.start_end[row][0]
|
|
1068
|
+
self._main().redraw_all(second=int(t))
|
|
1069
|
+
self.accept()
|
|
1070
|
+
|
|
1071
|
+
def _add(self):
|
|
1072
|
+
main = self._main()
|
|
1073
|
+
mianno = main.mianno
|
|
1074
|
+
if self._kind == "marker":
|
|
1075
|
+
labels = main.label_dialog.marker_label
|
|
1076
|
+
label, ok = QInputDialog.getItem(self, "Add marker", "Label:",
|
|
1077
|
+
labels, 0, True)
|
|
1078
|
+
if ok and label:
|
|
1079
|
+
mianno.marker.append([float(main.current_sec), label])
|
|
1080
|
+
else:
|
|
1081
|
+
labels = main.label_dialog.start_end_label
|
|
1082
|
+
label, ok = QInputDialog.getItem(self, "Add start-end", "Label:",
|
|
1083
|
+
labels, 0, True)
|
|
1084
|
+
if ok and label:
|
|
1085
|
+
start = float(main.current_sec)
|
|
1086
|
+
mianno.start_end.append([start, start + 5, label])
|
|
1087
|
+
self._refresh()
|
|
1088
|
+
self._after_change()
|
|
1089
|
+
|
|
1090
|
+
def _delete(self):
|
|
1091
|
+
row = self.list.currentRow()
|
|
1092
|
+
if row < 0:
|
|
1093
|
+
return
|
|
1094
|
+
mianno = self._main().mianno
|
|
1095
|
+
if self._kind == "marker":
|
|
1096
|
+
mianno.marker.pop(row)
|
|
1097
|
+
else:
|
|
1098
|
+
mianno.start_end.pop(row)
|
|
1099
|
+
self._refresh()
|
|
1100
|
+
self._after_change()
|
|
1101
|
+
|
|
1102
|
+
def _after_change(self):
|
|
1103
|
+
main = self._main()
|
|
1104
|
+
main.is_saved = False
|
|
1105
|
+
main.AnnotationPathLabel.setText("*Annotation path:")
|
|
1106
|
+
main.plot_signals()
|
|
1107
|
+
main.plot_hypo()
|