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.
Files changed (94) hide show
  1. misleep/__init__.py +111 -0
  2. misleep/__main__.py +16 -0
  3. misleep/analysis/__init__.py +23 -0
  4. misleep/analysis/auto_stage.py +158 -0
  5. misleep/analysis/detection.py +198 -0
  6. misleep/analysis/features.py +131 -0
  7. misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
  8. misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
  9. misleep/analysis/models/__init__.py +0 -0
  10. misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
  11. misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
  12. misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
  13. misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
  14. misleep/analysis/transformer/__init__.py +46 -0
  15. misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  16. misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  17. misleep/analysis/transformer/configs.py +52 -0
  18. misleep/analysis/transformer/data.py +362 -0
  19. misleep/analysis/transformer/inference.py +328 -0
  20. misleep/analysis/transformer/models.py +220 -0
  21. misleep/analysis/transformer/preprocessing.py +105 -0
  22. misleep/config/__init__.py +105 -0
  23. misleep/config/default_config.ini +38 -0
  24. misleep/data/__init__.py +13 -0
  25. misleep/data/annotation.py +114 -0
  26. misleep/data/midata.py +387 -0
  27. misleep/gui/__init__.py +23 -0
  28. misleep/gui/app.py +183 -0
  29. misleep/gui/config_dialog.py +429 -0
  30. misleep/gui/dialogs.py +1107 -0
  31. misleep/gui/event_filters.py +26 -0
  32. misleep/gui/main_window.py +2699 -0
  33. misleep/gui/qt_utils.py +277 -0
  34. misleep/gui/resources/__init__.py +9 -0
  35. misleep/gui/resources/entire_logo.png +0 -0
  36. misleep/gui/resources/logo.png +0 -0
  37. misleep/gui/resources/misleep.ico +0 -0
  38. misleep/gui/resources/misleep.qrc +7 -0
  39. misleep/gui/resources/misleep_rc.py +1324 -0
  40. misleep/gui/spec_window.py +187 -0
  41. misleep/gui/style.py +994 -0
  42. misleep/gui/uis/SWA_detect_dialog.ui +181 -0
  43. misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
  44. misleep/gui/uis/__init__.py +6 -0
  45. misleep/gui/uis/about.ui +88 -0
  46. misleep/gui/uis/about_ui.py +80 -0
  47. misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
  48. misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
  49. misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
  50. misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
  51. misleep/gui/uis/horizontal_line_dialog.ui +166 -0
  52. misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
  53. misleep/gui/uis/label_dialog.ui +65 -0
  54. misleep/gui/uis/label_dialog_ui.py +72 -0
  55. misleep/gui/uis/main_window.ui +871 -0
  56. misleep/gui/uis/main_window_ui.py +616 -0
  57. misleep/gui/uis/save_data_dialog.ui +95 -0
  58. misleep/gui/uis/save_data_dialog_ui.py +98 -0
  59. misleep/gui/uis/spec_window.ui +109 -0
  60. misleep/gui/uis/spec_window_ui.py +103 -0
  61. misleep/gui/uis/spindle_detect_dialog.ui +202 -0
  62. misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
  63. misleep/gui/uis/state_spectral_dialog.ui +240 -0
  64. misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
  65. misleep/gui/uis/transfer_result_dialog.ui +104 -0
  66. misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
  67. misleep/gui/workers.py +69 -0
  68. misleep/io/__init__.py +68 -0
  69. misleep/io/annotation.py +367 -0
  70. misleep/io/array.py +209 -0
  71. misleep/io/base.py +174 -0
  72. misleep/io/edf.py +109 -0
  73. misleep/io/mat.py +189 -0
  74. misleep/logger.py +79 -0
  75. misleep/preprocessing/__init__.py +23 -0
  76. misleep/preprocessing/artifacts.py +76 -0
  77. misleep/preprocessing/filtering.py +94 -0
  78. misleep/preprocessing/segment.py +59 -0
  79. misleep/preprocessing/spectral.py +169 -0
  80. misleep/utils/__init__.py +45 -0
  81. misleep/utils/annotation.py +130 -0
  82. misleep/utils/entropy.py +135 -0
  83. misleep/utils/misc.py +69 -0
  84. misleep/utils/time_utils.py +61 -0
  85. misleep/viz/__init__.py +12 -0
  86. misleep/viz/hypnogram.py +48 -0
  87. misleep/viz/signals.py +51 -0
  88. misleep/viz/spectral.py +98 -0
  89. misleep-0.3.0.dist-info/METADATA +249 -0
  90. misleep-0.3.0.dist-info/RECORD +94 -0
  91. misleep-0.3.0.dist-info/WHEEL +5 -0
  92. misleep-0.3.0.dist-info/entry_points.txt +15 -0
  93. misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
  94. 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 &rarr; Load Data (or drag a "
83
+ ".mat / .edf file onto the window). Optional: File &rarr; Load "
84
+ "Annotation.<br>",
85
+ "2. <b>Browse</b> - use the scroll bar, the arrow keys "
86
+ "(&larr;/&rarr; page, &uarr;/&darr; 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 &rarr; State Spectral / Transfer Result "
94
+ "saves Excel/PDF reports; File &rarr; Save Data exports the "
95
+ "signals.<br>",
96
+ "6. <b>Appearance</b> - Settings &rarr; 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()