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.
Potentially problematic release.
This version of misleep might be problematic. Click here for more details.
- 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
|
@@ -0,0 +1,2699 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Main window of the MiSleep GUI.
|
|
3
|
+
|
|
4
|
+
All plotting is based on the annotation: the signal area shows the raw
|
|
5
|
+
channels with sleep-state background colors, the spectrogram strip, the
|
|
6
|
+
hypnogram, and a collection of dock widgets for channel/scoring tools.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from matplotlib import pyplot as plt
|
|
18
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
19
|
+
from matplotlib.collections import PolyCollection
|
|
20
|
+
from PySide6.QtCore import QEvent, QTimer, Qt
|
|
21
|
+
from PySide6.QtGui import QColor, QKeySequence, QShortcut
|
|
22
|
+
from PySide6.QtWidgets import (
|
|
23
|
+
QAbstractItemView,
|
|
24
|
+
QAbstractSpinBox,
|
|
25
|
+
QApplication,
|
|
26
|
+
QComboBox,
|
|
27
|
+
QFileDialog,
|
|
28
|
+
QGridLayout,
|
|
29
|
+
QMainWindow,
|
|
30
|
+
QMessageBox,
|
|
31
|
+
QPushButton,
|
|
32
|
+
QScrollArea,
|
|
33
|
+
QSizePolicy,
|
|
34
|
+
QVBoxLayout,
|
|
35
|
+
QWidget,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from misleep.config import load_config, save_config, user_config_path
|
|
39
|
+
from misleep.data import MiAnnotation, MiData
|
|
40
|
+
from misleep.gui.config_dialog import SettingsDialog
|
|
41
|
+
from misleep.gui.dialogs import (
|
|
42
|
+
AboutDialog,
|
|
43
|
+
AutoStageCausalTransformerDialog,
|
|
44
|
+
AutoStageLightGBMDialog,
|
|
45
|
+
EventListDialog,
|
|
46
|
+
HorizontalLineDialog,
|
|
47
|
+
LabelDialog,
|
|
48
|
+
SaveDataDialog,
|
|
49
|
+
SpindleDetectionDialog,
|
|
50
|
+
StateSpectralDialog,
|
|
51
|
+
SWADetectionDialog,
|
|
52
|
+
TransferResultDialog,
|
|
53
|
+
UserGuideDialog,
|
|
54
|
+
)
|
|
55
|
+
from misleep.gui.event_filters import WheelInputGuard
|
|
56
|
+
from misleep.gui.qt_utils import (
|
|
57
|
+
ChannelListModel,
|
|
58
|
+
CollapsibleSection,
|
|
59
|
+
app_icon,
|
|
60
|
+
create_new_mianno,
|
|
61
|
+
identify_startend_color,
|
|
62
|
+
)
|
|
63
|
+
from misleep.gui.style import (
|
|
64
|
+
COLOR_TONES,
|
|
65
|
+
THEMES,
|
|
66
|
+
apply_theme,
|
|
67
|
+
resolved_theme,
|
|
68
|
+
retheme_figures,
|
|
69
|
+
)
|
|
70
|
+
from misleep.gui.uis.main_window_ui import Ui_MiSleep
|
|
71
|
+
from misleep.gui.workers import SaveThread
|
|
72
|
+
from misleep.io.annotation import available_annotation_readers, load_annotation
|
|
73
|
+
from misleep.io import available_readers, available_writers, load_signal
|
|
74
|
+
from misleep.logger import logger
|
|
75
|
+
from misleep.utils.annotation import lst2group
|
|
76
|
+
from misleep.viz.spectral import spectrogram_color_limits
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _text_on(color_hex: str) -> str:
|
|
80
|
+
"""Return black or white text for a background color (any theme)."""
|
|
81
|
+
c = QColor(color_hex)
|
|
82
|
+
lum = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()
|
|
83
|
+
return "#000000" if lum > 150 else "#ffffff"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MainWindow(QMainWindow, Ui_MiSleep):
|
|
87
|
+
"""Main window of MiSleep."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, parent=None):
|
|
90
|
+
self._startup_started = time.perf_counter()
|
|
91
|
+
super().__init__(parent)
|
|
92
|
+
self.setupUi(self)
|
|
93
|
+
|
|
94
|
+
# Load configuration first so the GUI can honor the user's theme.
|
|
95
|
+
self.config = load_config()
|
|
96
|
+
|
|
97
|
+
# Apply the selected theme (light / dark): Qt style + fonts +
|
|
98
|
+
# matplotlib defaults, so the plot area matches the chrome.
|
|
99
|
+
self._theme_name = self.config.get("gui", "theme", fallback="light")
|
|
100
|
+
if self._theme_name not in THEMES:
|
|
101
|
+
self._theme_name = "light"
|
|
102
|
+
self._tone_name = self.config.get("gui", "color_tone", fallback="black")
|
|
103
|
+
if self._tone_name not in COLOR_TONES:
|
|
104
|
+
self._tone_name = "black"
|
|
105
|
+
app = QApplication.instance()
|
|
106
|
+
if app is not None:
|
|
107
|
+
apply_theme(app, self._theme_name, self._tone_name)
|
|
108
|
+
self._set_plot_colors()
|
|
109
|
+
|
|
110
|
+
logger.info(f"Initializing MiSleep {self.config['gui']['version']}")
|
|
111
|
+
|
|
112
|
+
self.midata = None
|
|
113
|
+
self.mianno = None
|
|
114
|
+
|
|
115
|
+
# Original data and label file paths
|
|
116
|
+
self.data_path = ""
|
|
117
|
+
self.anno_path = ""
|
|
118
|
+
|
|
119
|
+
# Initial params
|
|
120
|
+
self.current_sec = 0
|
|
121
|
+
self.show_duration = 30 # Seconds of duration to plot
|
|
122
|
+
self.total_seconds = 0 # Total seconds for plot
|
|
123
|
+
self.y_lims = None # list of y lim for each channel
|
|
124
|
+
self.y_shift = None # list of y shift for each channel
|
|
125
|
+
self.show_idx = None # Channels to show in the plot area
|
|
126
|
+
self.state_map_dict = {int(key): value for key, value
|
|
127
|
+
in json.loads(self.config["gui"]["statemap"]).items()}
|
|
128
|
+
self.state_color_dict = {int(key): value for key, value
|
|
129
|
+
in json.loads(self.config["gui"]["statecolor"]).items()}
|
|
130
|
+
# Migrate the legacy white INIT default without overriding any custom
|
|
131
|
+
# non-white color chosen by the user.
|
|
132
|
+
if self.state_map_dict.get(4, "").strip().upper() == "INIT" and \
|
|
133
|
+
str(self.state_color_dict.get(4, "")).lower() in \
|
|
134
|
+
{"white", "#fff", "#ffffff"}:
|
|
135
|
+
self.state_color_dict[4] = "#ececec"
|
|
136
|
+
self.config["gui"]["statecolor"] = json.dumps(
|
|
137
|
+
{str(k): v for k, v in self.state_color_dict.items()})
|
|
138
|
+
self.start_end_color_dict = dict(json.loads(self.config["gui"]["startendcolor"].replace("'", '"')).items())
|
|
139
|
+
|
|
140
|
+
self.ShowRangeCombo_dict = {0: 30, 1: 60, 2: 300, 3: 1800, 4: 3600}
|
|
141
|
+
self.FilterTypeCombo_dict = {
|
|
142
|
+
0: "bandpass",
|
|
143
|
+
1: "highpass",
|
|
144
|
+
2: "lowpass",
|
|
145
|
+
3: "bandstop",
|
|
146
|
+
}
|
|
147
|
+
self.current_spectrogram_idx = 0
|
|
148
|
+
self.spectrogram_percentile = 99.7
|
|
149
|
+
self.show_midata = None
|
|
150
|
+
self.epoch_length = 5
|
|
151
|
+
self.ac_time = None
|
|
152
|
+
|
|
153
|
+
# Signal area figure
|
|
154
|
+
self.signal_figure = plt.figure()
|
|
155
|
+
self.signal_ax = self.signal_figure.subplots()
|
|
156
|
+
self.signal_figure.tight_layout(h_pad=0, w_pad=0)
|
|
157
|
+
self.signal_figure.subplots_adjust(hspace=0)
|
|
158
|
+
self.signal_canvas = FigureCanvas(self.signal_figure)
|
|
159
|
+
self.signal_canvas.mpl_connect("button_release_event", self.click_signal)
|
|
160
|
+
# start and end axvline, only two lines
|
|
161
|
+
self.signal_start_end_axvline = []
|
|
162
|
+
self.signal_marker_axvline = []
|
|
163
|
+
|
|
164
|
+
# Start-end for labels
|
|
165
|
+
self.start_end = []
|
|
166
|
+
# Start-end for milliseconds, for start_end label
|
|
167
|
+
self.start_end_ms = []
|
|
168
|
+
|
|
169
|
+
# Hypnogram area figure
|
|
170
|
+
self.hypo_figure = plt.figure(layout="constrained")
|
|
171
|
+
self.hypo_ax = self.hypo_figure.subplots()
|
|
172
|
+
self.hypo_canvas = FigureCanvas(self.hypo_figure)
|
|
173
|
+
self.hypo_canvas.mpl_connect("button_release_event", self.click_hypo)
|
|
174
|
+
self.hypo_axvline = self.hypo_ax.axvline(
|
|
175
|
+
self.current_sec, color="gray", alpha=0.8)
|
|
176
|
+
|
|
177
|
+
# Caches for fast page flips (spectrogram / hypnogram are static
|
|
178
|
+
# between flips, so they are computed once and reused).
|
|
179
|
+
self._spec_full_cache = {} # channel idx -> (f, t, Sxx) of whole file
|
|
180
|
+
self._spec_cache_max_sec = 4 * 3600 # longer files compute per window
|
|
181
|
+
self._hypo_key = None # fingerprint of the drawn hypnogram base
|
|
182
|
+
self._hypo_revision = 0 # increments only when states change
|
|
183
|
+
self._hypo_steps = [] # base step artists of the hypnogram
|
|
184
|
+
self._hypo_transient = [] # per-flip overlay artists
|
|
185
|
+
self._signal_artists = {} # signal-axes idx -> artists we own
|
|
186
|
+
self._spec_artist = None # current spectrogram QuadMesh
|
|
187
|
+
|
|
188
|
+
# Initial params for widgets
|
|
189
|
+
self.channel_slm = ChannelListModel()
|
|
190
|
+
|
|
191
|
+
# Initial dialogs and secondary windows
|
|
192
|
+
self.about_dialog = AboutDialog(version=self.config["gui"]["version"],
|
|
193
|
+
update_time=self.config["gui"]["updatetime"])
|
|
194
|
+
# Secondary analysis windows are created on first use. Constructing
|
|
195
|
+
# every Qt form during startup costs time even when most sessions only
|
|
196
|
+
# annotate signals.
|
|
197
|
+
self.spec_window = None
|
|
198
|
+
self.label_dialog = LabelDialog(config=self.config)
|
|
199
|
+
self.transfer_result_dialog = None
|
|
200
|
+
self.state_spectral_dialog = None
|
|
201
|
+
self.save_data_dialog = None
|
|
202
|
+
self.horizontal_line_dialog = None
|
|
203
|
+
# The horizontal_line dict contains the line value, line color, line comment
|
|
204
|
+
# example: {'ch1': [23.33, '#ff0000', '3 x Standard deviation', horizontalLineObject]}
|
|
205
|
+
self.horizontal_line = {}
|
|
206
|
+
self.axhline_horizontal = []
|
|
207
|
+
|
|
208
|
+
self.swa_detection_dialog = None
|
|
209
|
+
self.spindel_detection_dialog = None
|
|
210
|
+
self.auto_stage_lightGBM_dialog = None
|
|
211
|
+
self.auto_stage_causalTransformer_dialog = None
|
|
212
|
+
|
|
213
|
+
# Check whether operations are saved or not
|
|
214
|
+
self.is_saved = True
|
|
215
|
+
|
|
216
|
+
# Timer to auto-save annotations every 5 minutes
|
|
217
|
+
self.save_timer = QTimer()
|
|
218
|
+
self.save_timer.timeout.connect(self.auto_save)
|
|
219
|
+
|
|
220
|
+
# High-DPI / resolution: canvases track the scroll areas
|
|
221
|
+
self.SignalArea.installEventFilter(self)
|
|
222
|
+
self.HypnoArea.installEventFilter(self)
|
|
223
|
+
self.signal_canvas.installEventFilter(self)
|
|
224
|
+
self.hypo_canvas.installEventFilter(self)
|
|
225
|
+
# The mouse wheel over the signal / hypnogram panels flips pages
|
|
226
|
+
self.SignalArea.viewport().installEventFilter(self)
|
|
227
|
+
self.HypnoArea.viewport().installEventFilter(self)
|
|
228
|
+
|
|
229
|
+
# Unified right-hand sidebar (replaces the four separate docks)
|
|
230
|
+
self._build_sidebar()
|
|
231
|
+
self._rebuild_compact_tool_layouts()
|
|
232
|
+
self._constrain_inputs()
|
|
233
|
+
self.setMinimumSize(960, 600)
|
|
234
|
+
self.setWindowIcon(app_icon())
|
|
235
|
+
|
|
236
|
+
self.init_qt()
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Theme control
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
def _set_plot_colors(self):
|
|
242
|
+
"""Cache the theme colors used by the matplotlib canvases."""
|
|
243
|
+
theme = resolved_theme(self._theme_name, self._tone_name)
|
|
244
|
+
self._plot_trace = theme["plot"]["trace"]
|
|
245
|
+
self._plot_grid = theme["plot"]["grid"]
|
|
246
|
+
|
|
247
|
+
def _update_theme_action(self):
|
|
248
|
+
"""Keep the menu-bar theme action text in sync with the theme."""
|
|
249
|
+
if getattr(self, "theme_action", None) is not None:
|
|
250
|
+
self.theme_action.setText(
|
|
251
|
+
"Switch to Light Theme" if self._theme_name == "dark"
|
|
252
|
+
else "Switch to Dark Theme")
|
|
253
|
+
|
|
254
|
+
def _ensure_dialog(self, attribute, factory):
|
|
255
|
+
"""Create a secondary window only when the user first opens it."""
|
|
256
|
+
dialog = getattr(self, attribute)
|
|
257
|
+
if dialog is None:
|
|
258
|
+
dialog = factory()
|
|
259
|
+
setattr(self, attribute, dialog)
|
|
260
|
+
return dialog
|
|
261
|
+
|
|
262
|
+
@contextmanager
|
|
263
|
+
def _busy_operation(self):
|
|
264
|
+
"""Disable interaction during synchronous work and always restore it.
|
|
265
|
+
|
|
266
|
+
Export routines still contain libraries that must run on the GUI
|
|
267
|
+
thread (Qt dialogs and matplotlib). This guard prevents accidental
|
|
268
|
+
input during that work and, importantly, restores the window after
|
|
269
|
+
success, cancellation, or an exception.
|
|
270
|
+
"""
|
|
271
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
272
|
+
self.setEnabled(False)
|
|
273
|
+
QApplication.processEvents()
|
|
274
|
+
try:
|
|
275
|
+
yield
|
|
276
|
+
finally:
|
|
277
|
+
self.setEnabled(True)
|
|
278
|
+
QApplication.restoreOverrideCursor()
|
|
279
|
+
self.signal_canvas.update()
|
|
280
|
+
self.hypo_canvas.update()
|
|
281
|
+
self.update()
|
|
282
|
+
QApplication.processEvents()
|
|
283
|
+
|
|
284
|
+
def toggle_theme(self):
|
|
285
|
+
"""Toggle between the light and dark themes and redraw everything."""
|
|
286
|
+
self._theme_name = "dark" if self._theme_name == "light" else "light"
|
|
287
|
+
app = QApplication.instance()
|
|
288
|
+
if app is not None:
|
|
289
|
+
apply_theme(app, self._theme_name, self._tone_name)
|
|
290
|
+
retheme_figures(self._theme_name, self._tone_name)
|
|
291
|
+
self._set_plot_colors()
|
|
292
|
+
self._update_theme_action()
|
|
293
|
+
# Persist the choice so the next launch keeps it
|
|
294
|
+
self.save_config({"theme": self._theme_name})
|
|
295
|
+
|
|
296
|
+
if self.midata is not None and self.mianno is not None:
|
|
297
|
+
self.redraw_all(second=self.current_sec)
|
|
298
|
+
else:
|
|
299
|
+
self.clear_refresh(clf=True)
|
|
300
|
+
logger.info("Theme switched to %s", THEMES[self._theme_name]["name"])
|
|
301
|
+
|
|
302
|
+
def _build_sidebar(self):
|
|
303
|
+
"""Replace the four right docks with one tidy, fixed sidebar.
|
|
304
|
+
|
|
305
|
+
The dock contents are re-parented into collapsible sections
|
|
306
|
+
(Data / Channels / Scoring / Display) stacked in a single panel,
|
|
307
|
+
so the right side always looks clean regardless of window size.
|
|
308
|
+
"""
|
|
309
|
+
self._sections = []
|
|
310
|
+
sidebar = QWidget()
|
|
311
|
+
sidebar.setObjectName("Sidebar")
|
|
312
|
+
sidebar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
313
|
+
sidebar.setMinimumWidth(0)
|
|
314
|
+
sidebar.setSizePolicy(QSizePolicy.Policy.Ignored,
|
|
315
|
+
QSizePolicy.Policy.Preferred)
|
|
316
|
+
vbox = QVBoxLayout(sidebar)
|
|
317
|
+
vbox.setContentsMargins(0, 0, 0, 0)
|
|
318
|
+
vbox.setSpacing(8)
|
|
319
|
+
|
|
320
|
+
def add_section(title, content, collapsed=False, stretch=0):
|
|
321
|
+
sec = CollapsibleSection(title, content, collapsed=collapsed)
|
|
322
|
+
vbox.addWidget(sec, stretch)
|
|
323
|
+
self._sections.append(sec)
|
|
324
|
+
return sec
|
|
325
|
+
|
|
326
|
+
# Section titles keep the original dock names (Meta / Channel /
|
|
327
|
+
# Annotation / Time), so the sidebar reads exactly like the old GUI.
|
|
328
|
+
add_section("Meta", self.MetaDock.widget(), collapsed=True)
|
|
329
|
+
add_section("Channel", self.ChannelDock.widget(), stretch=1)
|
|
330
|
+
add_section("Annotation", self.AnnotationDock.widget())
|
|
331
|
+
add_section("Time", self.TimeDock.widget())
|
|
332
|
+
|
|
333
|
+
# The sidebar lives in a scroll area (never clips on small screens)
|
|
334
|
+
# and keeps a fixed, comfortable width next to the plot area.
|
|
335
|
+
self.sidebar = sidebar
|
|
336
|
+
scroll = QScrollArea()
|
|
337
|
+
scroll.setObjectName("SidebarScroll")
|
|
338
|
+
scroll.setWidgetResizable(True)
|
|
339
|
+
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
340
|
+
scroll.setWidget(sidebar)
|
|
341
|
+
scroll.setMinimumWidth(320)
|
|
342
|
+
scroll.setMaximumWidth(388)
|
|
343
|
+
scroll.setSizePolicy(QSizePolicy.Policy.Preferred,
|
|
344
|
+
QSizePolicy.Policy.Expanding)
|
|
345
|
+
scroll.setMinimumHeight(120)
|
|
346
|
+
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
347
|
+
self.sidebar_scroll = scroll
|
|
348
|
+
self.gridLayout_3.addWidget(scroll, 0, 1, 3, 1)
|
|
349
|
+
self.gridLayout_3.setColumnStretch(0, 1)
|
|
350
|
+
self.gridLayout_3.setColumnStretch(1, 0)
|
|
351
|
+
|
|
352
|
+
# tidy, even spacing inside every dock-content grid
|
|
353
|
+
for grid in (self.gridLayout, self.gridLayout_2, self.gridLayout_4):
|
|
354
|
+
grid.setSpacing(6)
|
|
355
|
+
grid.setContentsMargins(8, 6, 8, 8)
|
|
356
|
+
self.formLayout.setSpacing(6)
|
|
357
|
+
self.formLayout.setContentsMargins(8, 6, 8, 8)
|
|
358
|
+
# the channel list takes the section's extra height
|
|
359
|
+
self.gridLayout.setRowStretch(4, 1)
|
|
360
|
+
|
|
361
|
+
# vertical alignment in the Annotation section: the state buttons
|
|
362
|
+
# share equal columns, and the save/marker/list row stretches evenly
|
|
363
|
+
self.gridLayout_2.setColumnStretch(2, 1)
|
|
364
|
+
self.gridLayout_2.setColumnStretch(3, 1)
|
|
365
|
+
self.gridLayout_2.setColumnMinimumWidth(2, 72)
|
|
366
|
+
self.gridLayout_2.setColumnMinimumWidth(3, 72)
|
|
367
|
+
for bt in (self.SaveLabelBt, self.MarkerListBt, self.StartEndListBt):
|
|
368
|
+
bt.setSizePolicy(QSizePolicy.Policy.Expanding,
|
|
369
|
+
QSizePolicy.Policy.Fixed)
|
|
370
|
+
|
|
371
|
+
# The old docks are hidden - their widgets now live in the sidebar.
|
|
372
|
+
for dock in (self.MetaDock, self.ChannelDock,
|
|
373
|
+
self.AnnotationDock, self.TimeDock):
|
|
374
|
+
self.removeDockWidget(dock)
|
|
375
|
+
dock.hide()
|
|
376
|
+
|
|
377
|
+
def _rebuild_compact_tool_layouts(self):
|
|
378
|
+
"""Arrange the frequently used tools in predictable equal columns."""
|
|
379
|
+
# Annotation: three mutually-exclusive modes on one row, followed by
|
|
380
|
+
# one two-column state grid, two list buttons, and a full-width save.
|
|
381
|
+
anno = self.gridLayout_2
|
|
382
|
+
self.SleepStateRadio.setText("State")
|
|
383
|
+
self.LabelBt.hide()
|
|
384
|
+
self.line_4.hide()
|
|
385
|
+
for col, radio in enumerate(
|
|
386
|
+
(self.MarkerRadio, self.StartEndRadio, self.SleepStateRadio)):
|
|
387
|
+
anno.addWidget(radio, 0, col, 1, 1)
|
|
388
|
+
anno.setColumnStretch(col, 1)
|
|
389
|
+
anno.setColumnStretch(3, 0)
|
|
390
|
+
anno.setColumnMinimumWidth(3, 0)
|
|
391
|
+
|
|
392
|
+
anno.addWidget(self.ExtraStatePanel, 1, 0, 1, 3)
|
|
393
|
+
anno.addWidget(self.line_5, 2, 0, 1, 3)
|
|
394
|
+
save_row = self.SaveRowPanel.layout()
|
|
395
|
+
save_row.removeWidget(self.SaveLabelBt)
|
|
396
|
+
save_row.setStretch(0, 1)
|
|
397
|
+
save_row.setStretch(1, 1)
|
|
398
|
+
anno.addWidget(self.SaveRowPanel, 3, 0, 1, 3)
|
|
399
|
+
anno.addWidget(self.SaveLabelBt, 4, 0, 1, 3)
|
|
400
|
+
|
|
401
|
+
# Channel move controls get their own row below Percentile and line up
|
|
402
|
+
# with the three action buttons below the channel list.
|
|
403
|
+
channel = self.gridLayout
|
|
404
|
+
channel.addWidget(self.MoveChPanel, 2, 0, 1, 3)
|
|
405
|
+
move_row = self.MoveChPanel.layout()
|
|
406
|
+
move_row.setContentsMargins(0, 0, 0, 0)
|
|
407
|
+
move_row.setSpacing(6)
|
|
408
|
+
self.MoveLabel.setText("Move channel:")
|
|
409
|
+
for widget in (self.MoveLabel, self.MoveUpBt, self.MoveDownBt):
|
|
410
|
+
widget.setMinimumWidth(0)
|
|
411
|
+
# QWIDGETSIZE_MAX itself produces a warning in recent PySide6.
|
|
412
|
+
# This is still effectively unbounded inside the sidebar.
|
|
413
|
+
widget.setMaximumWidth(10000)
|
|
414
|
+
widget.setSizePolicy(QSizePolicy.Policy.Expanding,
|
|
415
|
+
QSizePolicy.Policy.Fixed)
|
|
416
|
+
move_row.setStretch(0, 1)
|
|
417
|
+
move_row.setStretch(1, 1)
|
|
418
|
+
move_row.setStretch(2, 1)
|
|
419
|
+
|
|
420
|
+
def _constrain_inputs(self):
|
|
421
|
+
"""Align sidebar editors without forcing cramped fixed widths."""
|
|
422
|
+
for name in ("FilterLowSpin", "FilterHighSpin", "PercentileSpin",
|
|
423
|
+
"SecondSpin", "SecondNumSpin", "multipleScalerEditor"):
|
|
424
|
+
spin = getattr(self, name, None)
|
|
425
|
+
if spin is not None:
|
|
426
|
+
spin.setMinimumWidth(76)
|
|
427
|
+
spin.setMaximumWidth(104)
|
|
428
|
+
for name in ("FilterTypeCombo",):
|
|
429
|
+
combo = getattr(self, name, None)
|
|
430
|
+
if combo is not None:
|
|
431
|
+
combo.setMinimumWidth(112)
|
|
432
|
+
combo.setMaximumWidth(156)
|
|
433
|
+
self.ShowRangeCombo.setMinimumWidth(0)
|
|
434
|
+
self.ShowRangeCombo.setMaximumWidth(16777215)
|
|
435
|
+
self.ShowRangeCombo.setSizePolicy(QSizePolicy.Policy.Expanding,
|
|
436
|
+
QSizePolicy.Policy.Fixed)
|
|
437
|
+
for edit in (self.DataPathEdit, self.AnnoPathEdit):
|
|
438
|
+
edit.setMinimumWidth(150)
|
|
439
|
+
edit.setToolTip(edit.text())
|
|
440
|
+
edit.textChanged.connect(edit.setToolTip)
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
# Initialization
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
def init_qt(self):
|
|
446
|
+
"""Wire up all Qt widget signals."""
|
|
447
|
+
# MenuBar triggers
|
|
448
|
+
self.actionAbout.triggered.connect(self.about_dialog.exec)
|
|
449
|
+
self.actionConfig.triggered.connect(self.open_settings_dialog)
|
|
450
|
+
self.actionLoadData.triggered.connect(self.load_data)
|
|
451
|
+
self.actionLoadAnnotation.triggered.connect(self.load_anno)
|
|
452
|
+
self.actionStateSpectral.triggered.connect(self.state_spectral)
|
|
453
|
+
self.actionTransferResult.triggered.connect(self.transfer_result)
|
|
454
|
+
self.actionAddLine.triggered.connect(self.add_horizontal_line)
|
|
455
|
+
self.actionSWA_detection.triggered.connect(self.swa_detection)
|
|
456
|
+
self.actionSpindle_Detection.triggered.connect(self.spindle_detection)
|
|
457
|
+
self.actionLightGBM.triggered.connect(self.auto_stage_LightGBM)
|
|
458
|
+
self.actionCausalTransformer.triggered.connect(self.auto_stage_CausalTransformer)
|
|
459
|
+
self.actionSaveData.triggered.connect(self.save_data)
|
|
460
|
+
|
|
461
|
+
# Spectrogram percentile
|
|
462
|
+
self.PercentileSpin.setValue(self.spectrogram_percentile)
|
|
463
|
+
self.PercentileSpin.setRange(0, 100)
|
|
464
|
+
self.PercentileSpin.valueChanged.connect(self.spec_percentile_change)
|
|
465
|
+
# Default spectrogram channel
|
|
466
|
+
self.DefaultCh4SpecBt.clicked.connect(self.default_ch4Spec)
|
|
467
|
+
|
|
468
|
+
# Scroll bar
|
|
469
|
+
self.ScrollerBar.valueChanged.connect(self.scroller_change)
|
|
470
|
+
self.ScrollerBar.setSingleStep(self.epoch_length)
|
|
471
|
+
self.ScrollerBar.setPageStep(self.show_duration)
|
|
472
|
+
|
|
473
|
+
# Time edits
|
|
474
|
+
self.SecondSpin.valueChanged.connect(self.SecondSpin_change)
|
|
475
|
+
self.DateTimeEdit.dateTimeChanged.connect(self.DateTimeEdit_change)
|
|
476
|
+
|
|
477
|
+
# Channel operations
|
|
478
|
+
self.ShowChBt.clicked.connect(self.show_chs)
|
|
479
|
+
self.HideChBt.clicked.connect(self.hide_chs)
|
|
480
|
+
self.DeleteChBt.clicked.connect(self.delete_chs)
|
|
481
|
+
self.ScalerUpBt.clicked.connect(self.scaler_up)
|
|
482
|
+
self.ScalerDownBt.clicked.connect(self.scaler_down)
|
|
483
|
+
self.ShiftUpBt.clicked.connect(self.shift_up)
|
|
484
|
+
self.ShiftDownBt.clicked.connect(self.shift_down)
|
|
485
|
+
self.channel_slm.dataChanged.connect(self.channel_rename)
|
|
486
|
+
# Channel order is changed with the Up/Down buttons (declared in the
|
|
487
|
+
# .ui); editing only starts on double-click.
|
|
488
|
+
self.ChListView.setEditTriggers(
|
|
489
|
+
QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
|
|
490
|
+
self.channel_slm.rowsMoved.connect(self._on_channel_list_changed)
|
|
491
|
+
self.channel_slm.rowsInserted.connect(self._on_channel_list_changed)
|
|
492
|
+
self.channel_slm.rowsRemoved.connect(self._on_channel_list_changed)
|
|
493
|
+
self._updating_ch_list = False
|
|
494
|
+
# Channel move arrows (declared in the .ui beside the percentile box)
|
|
495
|
+
self.MoveUpBt.clicked.connect(lambda: self.move_channel("up"))
|
|
496
|
+
self.MoveDownBt.clicked.connect(lambda: self.move_channel("down"))
|
|
497
|
+
# Make sure the move arrows always show their glyph (and an icon),
|
|
498
|
+
# even if a .ui rebuild ever drops the translated text.
|
|
499
|
+
if not self.MoveUpBt.text():
|
|
500
|
+
self.MoveUpBt.setText("\u25b2")
|
|
501
|
+
if not self.MoveDownBt.text():
|
|
502
|
+
self.MoveDownBt.setText("\u25bc")
|
|
503
|
+
|
|
504
|
+
# Filter
|
|
505
|
+
self.FilterTypeCombo.currentIndexChanged.connect(self.FilterTypeCombo_change)
|
|
506
|
+
self.FilterConfirmBt.clicked.connect(self.filter_confirm)
|
|
507
|
+
|
|
508
|
+
# Spectrum window
|
|
509
|
+
self.PlotSpecBt.clicked.connect(self.show_spec_window)
|
|
510
|
+
|
|
511
|
+
# Custom second spin edit and ShowRangeCombo
|
|
512
|
+
self.ShowRangeCombo.setEnabled(True)
|
|
513
|
+
self.SecondNumSpin.setDisabled(True)
|
|
514
|
+
self.SecondNumSpin.setRange(5, 3600)
|
|
515
|
+
self.SecondNumSpin.setValue(30)
|
|
516
|
+
self.SecondNumSpin.valueChanged.connect(self.SecondNumSpin_changed)
|
|
517
|
+
self.ShowRangeCombo.currentIndexChanged.connect(self.ShowRangeCombo_changed)
|
|
518
|
+
self.CustomSecondsCheck.clicked.connect(self.CustomSecondCheck_clicked)
|
|
519
|
+
|
|
520
|
+
# Label radio: start_end by default
|
|
521
|
+
self.SleepStateRadio.setChecked(True)
|
|
522
|
+
self.MarkerRadio.toggled.connect(lambda: self.radio_recheck(self.MarkerRadio))
|
|
523
|
+
self.SleepStateRadio.toggled.connect(lambda: self.radio_recheck(self.SleepStateRadio))
|
|
524
|
+
self.StartEndRadio.toggled.connect(lambda: self.radio_recheck(self.StartEndRadio))
|
|
525
|
+
|
|
526
|
+
# Label buttons
|
|
527
|
+
self.NREMBt.clicked.connect(self.nrem_label)
|
|
528
|
+
self.REMBt.clicked.connect(self.rem_label)
|
|
529
|
+
self.WakeBt.clicked.connect(self.wake_label)
|
|
530
|
+
self.InitBt.clicked.connect(self.init_label)
|
|
531
|
+
|
|
532
|
+
# Shortcuts
|
|
533
|
+
self.labelSc = QShortcut(QKeySequence("a"), self)
|
|
534
|
+
self.labelSc.activated.connect(self.append_start_end)
|
|
535
|
+
self.specSc = QShortcut(QKeySequence("s"), self)
|
|
536
|
+
self.specSc.activated.connect(self.show_spec_window)
|
|
537
|
+
|
|
538
|
+
self.next_pageSc = QShortcut(QKeySequence("Right"), self)
|
|
539
|
+
self.next_pageSc.activated.connect(self.next_page)
|
|
540
|
+
self.previous_pageSc = QShortcut(QKeySequence("Left"), self)
|
|
541
|
+
self.previous_pageSc.activated.connect(self.previous_page)
|
|
542
|
+
self.next_epochSc = QShortcut(QKeySequence("Down"), self)
|
|
543
|
+
self.next_epochSc.activated.connect(self.next_epoch)
|
|
544
|
+
self.previous_epochSc = QShortcut(QKeySequence("Up"), self)
|
|
545
|
+
self.previous_epochSc.activated.connect(self.previous_epoch)
|
|
546
|
+
|
|
547
|
+
# NOTE: page-flipping with the mouse wheel is handled only by the
|
|
548
|
+
# event filter on the signal / hypnogram areas (see eventFilter),
|
|
549
|
+
# so scrolling over docks/tools never moves the signal window.
|
|
550
|
+
|
|
551
|
+
# Load data and annotation shortcuts
|
|
552
|
+
self.load_dataSc = QShortcut(QKeySequence("Shift+D"), self)
|
|
553
|
+
self.load_dataSc.activated.connect(self.load_data)
|
|
554
|
+
self.load_annoSc = QShortcut(QKeySequence("Shift+A"), self)
|
|
555
|
+
self.load_annoSc.activated.connect(self.load_anno)
|
|
556
|
+
|
|
557
|
+
# Save labels
|
|
558
|
+
self.SaveLabelBt.clicked.connect(self.save_anno)
|
|
559
|
+
self.saveSc = QShortcut(QKeySequence("Ctrl+S"), self)
|
|
560
|
+
self.saveSc.activated.connect(self.save_anno)
|
|
561
|
+
|
|
562
|
+
# Dynamic state-label buttons in the Annotation dock
|
|
563
|
+
self._sync_state_buttons()
|
|
564
|
+
|
|
565
|
+
# Scalar input
|
|
566
|
+
self.MultipleScalerConfirmBt.clicked.connect(self.multiple_scaler)
|
|
567
|
+
|
|
568
|
+
self.change_Bts_status(True)
|
|
569
|
+
self._build_menus()
|
|
570
|
+
# Marker / start-end event list viewers (declared in the .ui beside Save)
|
|
571
|
+
self.MarkerListBt.clicked.connect(self.show_marker_list)
|
|
572
|
+
self.StartEndListBt.clicked.connect(self.show_start_end_list)
|
|
573
|
+
logger.info("MiSleep ready in %.3f s - open a data file to start",
|
|
574
|
+
time.perf_counter() - self._startup_started)
|
|
575
|
+
|
|
576
|
+
# Wheel over spin boxes / combos must not change their values
|
|
577
|
+
app = QApplication.instance()
|
|
578
|
+
if app is not None:
|
|
579
|
+
self._wheel_input_guard = WheelInputGuard(app)
|
|
580
|
+
app.installEventFilter(self._wheel_input_guard)
|
|
581
|
+
|
|
582
|
+
def change_Bts_status(self, status=True):
|
|
583
|
+
"""Enable/disable buttons according to whether data is loaded."""
|
|
584
|
+
meta_content = self.MetaDock.widget()
|
|
585
|
+
if meta_content is not None:
|
|
586
|
+
meta_content.setDisabled(status)
|
|
587
|
+
self.AcTimeEdit.setDisabled(status)
|
|
588
|
+
self.DeleteChBt.setDisabled(status)
|
|
589
|
+
self.HideChBt.setDisabled(status)
|
|
590
|
+
self.ScalerDownBt.setDisabled(status)
|
|
591
|
+
self.ShiftDownBt.setDisabled(status)
|
|
592
|
+
self.ShowChBt.setDisabled(status)
|
|
593
|
+
self.ScalerUpBt.setDisabled(status)
|
|
594
|
+
self.ShiftUpBt.setDisabled(status)
|
|
595
|
+
self.FilterConfirmBt.setDisabled(status)
|
|
596
|
+
self.DefaultCh4SpecBt.setDisabled(status)
|
|
597
|
+
self.FilterTypeCombo.setDisabled(status)
|
|
598
|
+
self.PlotSpecBt.setDisabled(status)
|
|
599
|
+
self.LabelBt.setDisabled(status)
|
|
600
|
+
self.StartEndRadio.setDisabled(status)
|
|
601
|
+
self.SleepStateRadio.setDisabled(status)
|
|
602
|
+
self.SaveLabelBt.setDisabled(status)
|
|
603
|
+
self.MarkerRadio.setDisabled(status)
|
|
604
|
+
self.MarkerListBt.setDisabled(status)
|
|
605
|
+
self.StartEndListBt.setDisabled(status)
|
|
606
|
+
self.MoveUpBt.setDisabled(status)
|
|
607
|
+
self.MoveDownBt.setDisabled(status)
|
|
608
|
+
for bt in getattr(self, "_state_btns", {}).values():
|
|
609
|
+
bt.setDisabled(status)
|
|
610
|
+
self.DateTimeEdit.setDisabled(status)
|
|
611
|
+
self.ShowRangeCombo.setDisabled(status)
|
|
612
|
+
self.SecondSpin.setDisabled(status)
|
|
613
|
+
self.labelSc.setEnabled(not status)
|
|
614
|
+
self.specSc.setEnabled(not status)
|
|
615
|
+
self.next_pageSc.setEnabled(not status)
|
|
616
|
+
self.previous_pageSc.setEnabled(not status)
|
|
617
|
+
self.next_epochSc.setEnabled(not status)
|
|
618
|
+
self.previous_epochSc.setEnabled(not status)
|
|
619
|
+
self.multipleScalerEditor.setDisabled(status)
|
|
620
|
+
self.MultipleScalerConfirmBt.setDisabled(status)
|
|
621
|
+
|
|
622
|
+
self.menuTools.setDisabled(status)
|
|
623
|
+
self.menuResult.setDisabled(status)
|
|
624
|
+
self._update_annotation_mode()
|
|
625
|
+
self._update_channel_action_states()
|
|
626
|
+
|
|
627
|
+
def _selected_channel_rows(self):
|
|
628
|
+
"""Return unique selected channel rows in view order."""
|
|
629
|
+
return sorted({index.row() for index in self.ChListView.selectedIndexes()})
|
|
630
|
+
|
|
631
|
+
def _update_channel_action_states(self, *_args):
|
|
632
|
+
"""Keep all channel actions synchronized with the list selection."""
|
|
633
|
+
loaded = self.midata is not None
|
|
634
|
+
selected = self._selected_channel_rows() if loaded else []
|
|
635
|
+
has_selection = bool(selected)
|
|
636
|
+
one_selected = len(selected) == 1
|
|
637
|
+
for widget in (
|
|
638
|
+
self.ShowChBt, self.HideChBt, self.DeleteChBt,
|
|
639
|
+
self.ScalerUpBt, self.ScalerDownBt, self.ShiftUpBt,
|
|
640
|
+
self.ShiftDownBt, self.multipleScalerEditor,
|
|
641
|
+
self.MultipleScalerConfirmBt, self.FilterTypeCombo,
|
|
642
|
+
self.FilterLowSpin, self.FilterHighSpin):
|
|
643
|
+
widget.setEnabled(loaded and has_selection)
|
|
644
|
+
self.FilterConfirmBt.setEnabled(loaded and one_selected)
|
|
645
|
+
self.DefaultCh4SpecBt.setEnabled(loaded and one_selected)
|
|
646
|
+
self.PlotSpecBt.setEnabled(loaded and one_selected)
|
|
647
|
+
self.MoveUpBt.setEnabled(loaded and one_selected and selected[0] > 0)
|
|
648
|
+
self.MoveDownBt.setEnabled(
|
|
649
|
+
loaded and one_selected and selected[0] < self.midata.n_channels - 1)
|
|
650
|
+
if loaded and has_selection:
|
|
651
|
+
self.FilterTypeCombo_change()
|
|
652
|
+
|
|
653
|
+
def _update_annotation_mode(self, *_args):
|
|
654
|
+
"""Enable state choices only while the State mode is active."""
|
|
655
|
+
active = self.midata is not None and self.SleepStateRadio.isChecked()
|
|
656
|
+
for bt in getattr(self, "_state_btns", {}).values():
|
|
657
|
+
bt.setEnabled(active)
|
|
658
|
+
|
|
659
|
+
def _build_menus(self):
|
|
660
|
+
"""Polish the menu bar: File extras, Settings, Help with user guide."""
|
|
661
|
+
# File: add "Save Annotation" and "Exit"
|
|
662
|
+
save_anno_action = self.menuFile.addAction(app_icon(), "Save Annotation", self.save_anno)
|
|
663
|
+
save_anno_action.setShortcut(QKeySequence("Ctrl+S"))
|
|
664
|
+
self.menuFile.insertAction(self.actionSaveData, save_anno_action)
|
|
665
|
+
self.menuFile.addSeparator()
|
|
666
|
+
self.menuFile.addAction("Exit", self.close)
|
|
667
|
+
|
|
668
|
+
# Help: Settings lives on its own top-level menu, not in About
|
|
669
|
+
self.menuHelp.removeAction(self.actionConfig)
|
|
670
|
+
# Help: About + User Guide (with a link to the online documentation)
|
|
671
|
+
self.menuHelp.addSeparator()
|
|
672
|
+
self.menuHelp.addAction(app_icon(), "User Guide…", self.show_user_guide)
|
|
673
|
+
|
|
674
|
+
# A dedicated Settings menu, placed between Result and Help
|
|
675
|
+
settings_menu = self.menuBar.addMenu("Settings")
|
|
676
|
+
settings_action = settings_menu.addAction(
|
|
677
|
+
app_icon(), "Settings…", self.open_settings_dialog)
|
|
678
|
+
settings_action.setToolTip("Open the in-application settings dialog")
|
|
679
|
+
settings_menu.addSeparator()
|
|
680
|
+
self.theme_action = settings_menu.addAction(
|
|
681
|
+
"Switch to Dark Theme", self.toggle_theme)
|
|
682
|
+
self.theme_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
|
|
683
|
+
self._update_theme_action()
|
|
684
|
+
self.menuBar.insertMenu(self.menuHelp.menuAction(), settings_menu)
|
|
685
|
+
|
|
686
|
+
def show_user_guide(self):
|
|
687
|
+
"""Open the in-app user guide (brief usage notes + online docs)."""
|
|
688
|
+
dialog = UserGuideDialog(self)
|
|
689
|
+
dialog.exec()
|
|
690
|
+
|
|
691
|
+
# ------------------------------------------------------------------
|
|
692
|
+
# Data / annotation loading
|
|
693
|
+
# ------------------------------------------------------------------
|
|
694
|
+
def load_data(self):
|
|
695
|
+
"""Triggered by actionLoadData: ask for a signal file and open it."""
|
|
696
|
+
extensions = available_readers()
|
|
697
|
+
patterns = " ".join(f"*{ext}" for ext in extensions)
|
|
698
|
+
format_filters = ";;".join(
|
|
699
|
+
f"{ext[1:].upper()} files (*{ext})" for ext in extensions)
|
|
700
|
+
data_path, _ = QFileDialog.getOpenFileName(
|
|
701
|
+
self, "Select data file",
|
|
702
|
+
f"{self.config['gui']['openpath']}",
|
|
703
|
+
f"All supported signals ({patterns});;{format_filters};;All files (*)")
|
|
704
|
+
|
|
705
|
+
if data_path == "":
|
|
706
|
+
return
|
|
707
|
+
self.open_data(data_path)
|
|
708
|
+
|
|
709
|
+
def open_data(self, data_path):
|
|
710
|
+
"""Load a signal file (no file dialog) and show it.
|
|
711
|
+
|
|
712
|
+
Used both by the GUI's file dialog and when MiSleep is launched
|
|
713
|
+
with a data file argument (``misleep data.mat``) or by
|
|
714
|
+
double-clicking a registered file.
|
|
715
|
+
|
|
716
|
+
Parameters
|
|
717
|
+
----------
|
|
718
|
+
data_path : str
|
|
719
|
+
Path of any registered signal file to load.
|
|
720
|
+
"""
|
|
721
|
+
load_started = time.perf_counter()
|
|
722
|
+
if not os.path.exists(data_path):
|
|
723
|
+
QMessageBox.about(
|
|
724
|
+
self, "Error",
|
|
725
|
+
f"Data file not found:\n{data_path}")
|
|
726
|
+
return
|
|
727
|
+
|
|
728
|
+
self.data_path = data_path
|
|
729
|
+
self.midata = None
|
|
730
|
+
try:
|
|
731
|
+
self.midata = load_signal(self.data_path)
|
|
732
|
+
if not isinstance(self.midata, MiData):
|
|
733
|
+
raise ValueError("The reader did not return valid MiData")
|
|
734
|
+
except Exception as exc:
|
|
735
|
+
logger.exception("Could not load signal file %s", self.data_path)
|
|
736
|
+
QMessageBox.critical(
|
|
737
|
+
self, "Could not load data",
|
|
738
|
+
f"MiSleep could not open this signal file.\n\n{exc}")
|
|
739
|
+
self.data_path = ""
|
|
740
|
+
self.change_Bts_status(True)
|
|
741
|
+
self.mianno = None
|
|
742
|
+
return
|
|
743
|
+
read_elapsed = time.perf_counter() - load_started
|
|
744
|
+
|
|
745
|
+
# Save config
|
|
746
|
+
self.save_config({"openpath": self.data_path})
|
|
747
|
+
|
|
748
|
+
# New file: drop the cached whole-file spectrograms
|
|
749
|
+
self._spec_full_cache = {}
|
|
750
|
+
|
|
751
|
+
# Set meta info
|
|
752
|
+
self.DataPathEdit.setText(self.data_path)
|
|
753
|
+
self.ac_time = datetime.datetime.strptime(self.midata.time, "%Y%m%d-%H:%M:%S")
|
|
754
|
+
self.AcTimeEdit.setDateTime(self.ac_time)
|
|
755
|
+
|
|
756
|
+
# Set channel infos
|
|
757
|
+
self.fill_channel_listView()
|
|
758
|
+
|
|
759
|
+
self.start_end = []
|
|
760
|
+
self.current_spectrogram_idx = 0
|
|
761
|
+
self.mianno = None
|
|
762
|
+
self.anno_path = ""
|
|
763
|
+
self.AnnoPathEdit.setText("")
|
|
764
|
+
|
|
765
|
+
self.clear_refresh(clf=True, draw=False)
|
|
766
|
+
|
|
767
|
+
try:
|
|
768
|
+
self.check_show()
|
|
769
|
+
except Exception as e:
|
|
770
|
+
logger.error(f"Check Show ERROR: {e}")
|
|
771
|
+
logger.info("Data timing: read %.3f s, initialize/draw %.3f s",
|
|
772
|
+
read_elapsed, time.perf_counter() - load_started - read_elapsed)
|
|
773
|
+
|
|
774
|
+
def load_anno(self):
|
|
775
|
+
"""Triggered by actionLoadAnnotation: ask for an annotation file."""
|
|
776
|
+
extensions = available_annotation_readers()
|
|
777
|
+
patterns = " ".join(f"*{ext}" for ext in extensions)
|
|
778
|
+
anno_path, _ = QFileDialog.getOpenFileName(
|
|
779
|
+
self, "Select annotation file",
|
|
780
|
+
f"{self.config['gui']['openpath']}",
|
|
781
|
+
f"All supported annotations ({patterns});;All files (*)")
|
|
782
|
+
|
|
783
|
+
if anno_path == "":
|
|
784
|
+
return
|
|
785
|
+
self.open_annotation(anno_path)
|
|
786
|
+
|
|
787
|
+
def open_annotation(self, anno_path):
|
|
788
|
+
"""Load an annotation file (no file dialog).
|
|
789
|
+
|
|
790
|
+
Used both by the GUI's file dialog and when MiSleep is launched
|
|
791
|
+
with an annotation argument (``misleep data.mat anno.txt``).
|
|
792
|
+
|
|
793
|
+
Parameters
|
|
794
|
+
----------
|
|
795
|
+
anno_path : str
|
|
796
|
+
Path of the annotation ``.txt`` file.
|
|
797
|
+
"""
|
|
798
|
+
load_started = time.perf_counter()
|
|
799
|
+
if not os.path.exists(anno_path):
|
|
800
|
+
QMessageBox.about(self, "Error", f"Annotation file not found:\n{anno_path}")
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
self.anno_path = anno_path
|
|
804
|
+
_mianno = self.mianno
|
|
805
|
+
|
|
806
|
+
try:
|
|
807
|
+
self.mianno = load_annotation(self.anno_path, state_map=self.state_map_dict)
|
|
808
|
+
except AssertionError as e:
|
|
809
|
+
if e.args and e.args[0] == "Empty":
|
|
810
|
+
if isinstance(self.midata, MiData):
|
|
811
|
+
self.mianno = create_new_mianno(self.midata.duration)
|
|
812
|
+
else:
|
|
813
|
+
QMessageBox.about(
|
|
814
|
+
self, "Error",
|
|
815
|
+
"To create a new annotation file, load a data file first.")
|
|
816
|
+
self.anno_path = ""
|
|
817
|
+
self.change_Bts_status(True)
|
|
818
|
+
self.mianno = None
|
|
819
|
+
return
|
|
820
|
+
else:
|
|
821
|
+
QMessageBox.critical(self, "Could not load annotation",
|
|
822
|
+
"The annotation file is invalid.")
|
|
823
|
+
self.anno_path = ""
|
|
824
|
+
self.mianno = _mianno
|
|
825
|
+
return
|
|
826
|
+
except Exception as exc:
|
|
827
|
+
logger.exception("Could not load annotation file %s", self.anno_path)
|
|
828
|
+
QMessageBox.critical(self, "Could not load annotation",
|
|
829
|
+
f"MiSleep could not open this annotation.\n\n{exc}")
|
|
830
|
+
self.anno_path = ""
|
|
831
|
+
self.mianno = _mianno
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
# Save config
|
|
835
|
+
self.save_config({"openpath": self.anno_path})
|
|
836
|
+
|
|
837
|
+
# Set meta info
|
|
838
|
+
self.AnnoPathEdit.setText(self.anno_path)
|
|
839
|
+
|
|
840
|
+
self.clear_refresh(clf=True, draw=False)
|
|
841
|
+
|
|
842
|
+
try:
|
|
843
|
+
self.check_show(_mianno)
|
|
844
|
+
except Exception as e:
|
|
845
|
+
logger.error(f"Check Show ERROR: {e}")
|
|
846
|
+
logger.info("Annotation load and redraw completed in %.3f s",
|
|
847
|
+
time.perf_counter() - load_started)
|
|
848
|
+
|
|
849
|
+
def check_show(self, mianno=None):
|
|
850
|
+
"""Validate data/annotation and draw everything.
|
|
851
|
+
|
|
852
|
+
Parameters
|
|
853
|
+
----------
|
|
854
|
+
mianno : MiAnnotation, optional
|
|
855
|
+
Keep the previous annotation when loading a new one failed.
|
|
856
|
+
"""
|
|
857
|
+
if not isinstance(self.midata, MiData):
|
|
858
|
+
QMessageBox.about(self, "Error", "Load data file first.")
|
|
859
|
+
self.midata = None
|
|
860
|
+
self.mianno = None
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
# heal the canvases before (re)attaching them to the scroll areas
|
|
864
|
+
self._repair_canvases()
|
|
865
|
+
|
|
866
|
+
if isinstance(self.midata, MiData) and not isinstance(self.mianno, MiAnnotation):
|
|
867
|
+
self.mianno = create_new_mianno(self.midata.duration)
|
|
868
|
+
self._hypo_revision += 1
|
|
869
|
+
|
|
870
|
+
self.show_idx = list(range(self.midata.n_channels))
|
|
871
|
+
self.y_lims = [float(np.max(np.abs(each[:1000])))
|
|
872
|
+
for each in self.midata.signals]
|
|
873
|
+
self.y_lims = [1e-3 if each == 0.0 else each for each in self.y_lims]
|
|
874
|
+
self.y_shift = [0 for _ in range(self.midata.n_channels)]
|
|
875
|
+
|
|
876
|
+
if abs(self.midata.duration - self.mianno.anno_length) >= 600:
|
|
877
|
+
QMessageBox.about(self, "Error", "Data and annotation do not match!")
|
|
878
|
+
self.anno_path = ""
|
|
879
|
+
self.AnnoPathEdit.setText(self.anno_path)
|
|
880
|
+
if mianno:
|
|
881
|
+
self.mianno = mianno
|
|
882
|
+
if not isinstance(self.mianno, MiAnnotation):
|
|
883
|
+
self.mianno = create_new_mianno(self.midata.duration)
|
|
884
|
+
self.total_seconds = self.midata.duration if \
|
|
885
|
+
self.midata.duration < self.mianno.anno_length else self.mianno.anno_length
|
|
886
|
+
self.reset_sec_limit()
|
|
887
|
+
|
|
888
|
+
self.hypo_ax = self.hypo_figure.subplots(nrows=1, ncols=1)
|
|
889
|
+
# new axes: the cached hypnogram base must be rebuilt
|
|
890
|
+
self._hypo_key = None
|
|
891
|
+
self._hypo_steps = []
|
|
892
|
+
self._hypo_transient = []
|
|
893
|
+
|
|
894
|
+
# Set canvases for the plot areas
|
|
895
|
+
# Keep the canvases hidden until their final axes and DPI-aware size
|
|
896
|
+
# are ready. Otherwise Qt briefly paints the tiny default figure and
|
|
897
|
+
# users see it grow after loading or adding a filtered channel.
|
|
898
|
+
self.signal_canvas.hide()
|
|
899
|
+
self.hypo_canvas.hide()
|
|
900
|
+
self.SignalArea.setWidget(self.signal_canvas)
|
|
901
|
+
self.HypnoArea.setWidget(self.hypo_canvas)
|
|
902
|
+
|
|
903
|
+
self.horizontal_line = {}
|
|
904
|
+
for channel in self.midata.channels:
|
|
905
|
+
self.horizontal_line[channel] = []
|
|
906
|
+
|
|
907
|
+
self.redraw_all(second=0)
|
|
908
|
+
self.change_Bts_status(False)
|
|
909
|
+
|
|
910
|
+
self.setWindowTitle(
|
|
911
|
+
f"MiSleep - {os.path.basename(self.data_path)} - {os.path.basename(self.anno_path)}")
|
|
912
|
+
self.start_end = []
|
|
913
|
+
self.mianno._state_map = self.state_map_dict
|
|
914
|
+
|
|
915
|
+
logger.info(f"Load SUCCEED: data - {self.data_path}, anno - {self.anno_path}")
|
|
916
|
+
|
|
917
|
+
# Load feedback
|
|
918
|
+
logger.info(
|
|
919
|
+
"Loaded: %s - %d channel(s), %d s",
|
|
920
|
+
os.path.basename(self.data_path),
|
|
921
|
+
self.midata.n_channels, self.total_seconds)
|
|
922
|
+
|
|
923
|
+
# Start the auto-save timer (5 min)
|
|
924
|
+
self.save_timer.start(60 * 5 * 1000)
|
|
925
|
+
|
|
926
|
+
# Fit the canvases to the (high-DPI aware) window size
|
|
927
|
+
self._fit_canvases()
|
|
928
|
+
self.signal_canvas.show()
|
|
929
|
+
self.hypo_canvas.show()
|
|
930
|
+
|
|
931
|
+
def reset_sec_limit(self):
|
|
932
|
+
"""Update scrollbar / spin / datetime limits when show duration changes."""
|
|
933
|
+
self.ScrollerBar.setRange(0, self.total_seconds - self.show_duration)
|
|
934
|
+
self.SecondSpin.setRange(0, self.total_seconds - self.show_duration)
|
|
935
|
+
|
|
936
|
+
self.DateTimeEdit.blockSignals(True)
|
|
937
|
+
self.DateTimeEdit.setDateTimeRange(
|
|
938
|
+
self.ac_time,
|
|
939
|
+
self.ac_time + datetime.timedelta(seconds=self.total_seconds - self.show_duration))
|
|
940
|
+
self.DateTimeEdit.blockSignals(False)
|
|
941
|
+
|
|
942
|
+
# ------------------------------------------------------------------
|
|
943
|
+
# Plotting
|
|
944
|
+
# ------------------------------------------------------------------
|
|
945
|
+
def clear_refresh(self, clf=False, draw=True):
|
|
946
|
+
"""Clear and refresh all plot canvases."""
|
|
947
|
+
if clf:
|
|
948
|
+
self.signal_figure.clf()
|
|
949
|
+
self.hypo_figure.clf()
|
|
950
|
+
# the old axes are gone; force a rebuild on the next plot
|
|
951
|
+
self.signal_ax = None
|
|
952
|
+
self._signal_artists = {}
|
|
953
|
+
self._spec_artist = None
|
|
954
|
+
self._hypo_key = None
|
|
955
|
+
self._hypo_steps = []
|
|
956
|
+
self._hypo_transient = []
|
|
957
|
+
|
|
958
|
+
if not draw:
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
self.hypo_figure.canvas.draw()
|
|
962
|
+
self.hypo_figure.canvas.flush_events()
|
|
963
|
+
self.signal_figure.canvas.draw()
|
|
964
|
+
self.signal_figure.canvas.flush_events()
|
|
965
|
+
|
|
966
|
+
def _repair_canvases(self):
|
|
967
|
+
"""Recreate a main canvas whose matplotlib figure was closed.
|
|
968
|
+
|
|
969
|
+
A stray pyplot ``close()`` in some export routine used to kill the
|
|
970
|
+
main figures, freezing the signal/hypnogram panels. With the
|
|
971
|
+
source fixed this should never happen, but the guard keeps the
|
|
972
|
+
window self-healing anyway.
|
|
973
|
+
"""
|
|
974
|
+
import matplotlib.pyplot as plt
|
|
975
|
+
|
|
976
|
+
def alive(fig):
|
|
977
|
+
try:
|
|
978
|
+
return (plt.fignum_exists(fig.number)
|
|
979
|
+
and plt.figure(fig.number) is fig)
|
|
980
|
+
except Exception: # pragma: no cover
|
|
981
|
+
return False
|
|
982
|
+
|
|
983
|
+
if not alive(self.signal_figure):
|
|
984
|
+
self.signal_figure = plt.figure()
|
|
985
|
+
self.signal_ax = None
|
|
986
|
+
self.signal_canvas = FigureCanvas(self.signal_figure)
|
|
987
|
+
self.signal_canvas.mpl_connect("button_release_event", self.click_signal)
|
|
988
|
+
self.signal_canvas.installEventFilter(self)
|
|
989
|
+
self.SignalArea.setWidget(self.signal_canvas)
|
|
990
|
+
self._signal_artists = {}
|
|
991
|
+
self._spec_artist = None
|
|
992
|
+
logger.warning("Signal canvas was closed externally - recreated")
|
|
993
|
+
|
|
994
|
+
if not alive(self.hypo_figure):
|
|
995
|
+
self.hypo_figure = plt.figure(layout="constrained")
|
|
996
|
+
self.hypo_ax = self.hypo_figure.subplots()
|
|
997
|
+
self.hypo_canvas = FigureCanvas(self.hypo_figure)
|
|
998
|
+
self.hypo_canvas.mpl_connect("button_release_event", self.click_hypo)
|
|
999
|
+
self.hypo_canvas.installEventFilter(self)
|
|
1000
|
+
self.HypnoArea.setWidget(self.hypo_canvas)
|
|
1001
|
+
self._hypo_key = None
|
|
1002
|
+
self._hypo_steps = []
|
|
1003
|
+
self._hypo_transient = []
|
|
1004
|
+
logger.warning("Hypnogram canvas was closed externally - recreated")
|
|
1005
|
+
|
|
1006
|
+
def _fit_canvases(self):
|
|
1007
|
+
"""Resize the matplotlib figures to match their widgets.
|
|
1008
|
+
|
|
1009
|
+
The widget size is converted to **device pixels** (multiplied by
|
|
1010
|
+
``devicePixelRatio``) before being fed to ``set_size_inches``, so
|
|
1011
|
+
the figure fills the widget exactly on high-DPI / scaled displays
|
|
1012
|
+
(e.g. 2K at 125-150 %). A modest minimum keeps scrollbars instead
|
|
1013
|
+
of a crushed plot.
|
|
1014
|
+
"""
|
|
1015
|
+
try:
|
|
1016
|
+
sig_dpr = self.signal_canvas.devicePixelRatioF() or 1.0
|
|
1017
|
+
sig_w = self.signal_canvas.width() * sig_dpr
|
|
1018
|
+
sig_h = self.signal_canvas.height() * sig_dpr
|
|
1019
|
+
if sig_w < 50 or sig_h < 50:
|
|
1020
|
+
sig_w = self.SignalArea.viewport().width() * sig_dpr
|
|
1021
|
+
sig_h = self.SignalArea.viewport().height() * sig_dpr
|
|
1022
|
+
if sig_w > 50 and sig_h > 50:
|
|
1023
|
+
self.signal_figure.set_size_inches(
|
|
1024
|
+
sig_w / self.signal_figure.dpi,
|
|
1025
|
+
sig_h / self.signal_figure.dpi)
|
|
1026
|
+
self.signal_canvas.setMinimumSize(300, 200)
|
|
1027
|
+
self._apply_signal_layout()
|
|
1028
|
+
self.signal_figure.canvas.draw_idle()
|
|
1029
|
+
|
|
1030
|
+
hypo_dpr = self.hypo_canvas.devicePixelRatioF() or 1.0
|
|
1031
|
+
hypo_w = self.hypo_canvas.width() * hypo_dpr
|
|
1032
|
+
hypo_h = self.hypo_canvas.height() * hypo_dpr
|
|
1033
|
+
if hypo_w < 50 or hypo_h < 50:
|
|
1034
|
+
hypo_w = self.HypnoArea.viewport().width() * hypo_dpr
|
|
1035
|
+
hypo_h = self.HypnoArea.viewport().height() * hypo_dpr
|
|
1036
|
+
if hypo_w > 50 and hypo_h > 50:
|
|
1037
|
+
self.hypo_figure.set_size_inches(
|
|
1038
|
+
hypo_w / self.hypo_figure.dpi,
|
|
1039
|
+
max(1.0, hypo_h / self.hypo_figure.dpi))
|
|
1040
|
+
self.hypo_canvas.setMinimumSize(300, 60)
|
|
1041
|
+
self.hypo_figure.canvas.draw_idle()
|
|
1042
|
+
except Exception as e: # pragma: no cover
|
|
1043
|
+
logger.debug(f"fit canvases skipped: {e}")
|
|
1044
|
+
|
|
1045
|
+
def eventFilter(self, obj, event):
|
|
1046
|
+
"""Track canvas/scroll-area resizes; flip pages on mouse wheel."""
|
|
1047
|
+
if event.type() == QEvent.Type.Resize:
|
|
1048
|
+
if obj in (self.signal_canvas, self.hypo_canvas,
|
|
1049
|
+
self.SignalArea, self.HypnoArea):
|
|
1050
|
+
self._fit_canvases()
|
|
1051
|
+
elif event.type() == QEvent.Type.Wheel:
|
|
1052
|
+
if obj in (self.SignalArea.viewport(), self.HypnoArea.viewport(),
|
|
1053
|
+
self.signal_canvas, self.hypo_canvas):
|
|
1054
|
+
delta = event.angleDelta().y()
|
|
1055
|
+
if delta < 0:
|
|
1056
|
+
self.next_page()
|
|
1057
|
+
elif delta > 0:
|
|
1058
|
+
self.previous_page()
|
|
1059
|
+
return True
|
|
1060
|
+
return super().eventFilter(obj, event)
|
|
1061
|
+
|
|
1062
|
+
def resizeEvent(self, event):
|
|
1063
|
+
"""Keep the matplotlib canvases in sync with the window size."""
|
|
1064
|
+
super().resizeEvent(event)
|
|
1065
|
+
self._fit_canvases()
|
|
1066
|
+
|
|
1067
|
+
def showEvent(self, event):
|
|
1068
|
+
"""Fit the canvases once the window is actually shown."""
|
|
1069
|
+
super().showEvent(event)
|
|
1070
|
+
QTimer.singleShot(0, self._fit_canvases)
|
|
1071
|
+
|
|
1072
|
+
def _choose_tick_step(self, duration):
|
|
1073
|
+
"""Pick a tick interval (seconds) so ~6-24 x ticks are shown."""
|
|
1074
|
+
for s in (5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600):
|
|
1075
|
+
if duration / s <= 24:
|
|
1076
|
+
return s
|
|
1077
|
+
return 3600
|
|
1078
|
+
|
|
1079
|
+
def plot_signals(self, flush=True, clf=True, replot_axes=None):
|
|
1080
|
+
"""Plot the signal area: spectrogram strip + one box per channel.
|
|
1081
|
+
|
|
1082
|
+
Every channel gets its own axes (box) with its own y scale, so
|
|
1083
|
+
the boxes are independent. The figure fills the whole signal
|
|
1084
|
+
widget (see :meth:`_fit_canvases`).
|
|
1085
|
+
|
|
1086
|
+
Parameters
|
|
1087
|
+
----------
|
|
1088
|
+
flush : bool
|
|
1089
|
+
Whether to redraw the canvas.
|
|
1090
|
+
clf, replot_axes : kept for API compatibility (always full redraw).
|
|
1091
|
+
"""
|
|
1092
|
+
n = len(self.show_idx)
|
|
1093
|
+
need = n + 1 # spectrogram strip + one box per channel
|
|
1094
|
+
# Reuse the existing axes when the channel count is unchanged - much
|
|
1095
|
+
# faster than recreating them on every page flip.
|
|
1096
|
+
current = getattr(self, "signal_ax", None)
|
|
1097
|
+
structure_changed = current is None or not isinstance(current, (list, tuple)) \
|
|
1098
|
+
or len(current) != need
|
|
1099
|
+
if structure_changed:
|
|
1100
|
+
# No event-loop turn occurs between hide/show, so the user sees
|
|
1101
|
+
# only the completed layout, never matplotlib's resize stages.
|
|
1102
|
+
self.signal_canvas.hide()
|
|
1103
|
+
self.signal_figure.clf()
|
|
1104
|
+
self.signal_ax = list(self.signal_figure.subplots(
|
|
1105
|
+
nrows=need, ncols=1,
|
|
1106
|
+
gridspec_kw={"height_ratios": [0.8] + [1.0] * n}))
|
|
1107
|
+
self._signal_artists = {}
|
|
1108
|
+
self._spec_artist = None
|
|
1109
|
+
else:
|
|
1110
|
+
# Remove only the artists we own from the previous flip - far
|
|
1111
|
+
# cheaper than clearing the whole axes.
|
|
1112
|
+
for ax_idx, artists in self._signal_artists.items():
|
|
1113
|
+
for art in artists:
|
|
1114
|
+
try:
|
|
1115
|
+
art.remove()
|
|
1116
|
+
except Exception:
|
|
1117
|
+
pass
|
|
1118
|
+
self._signal_artists = {}
|
|
1119
|
+
self.plot_spectrogram()
|
|
1120
|
+
# visible frames around every panel (spectrogram strip + boxes)
|
|
1121
|
+
self._style_signal_boxes()
|
|
1122
|
+
|
|
1123
|
+
# Sleep-state groups inside the current window
|
|
1124
|
+
sleep_state = self.mianno.sleep_state[
|
|
1125
|
+
self.current_sec: self.current_sec + self.show_duration + 1]
|
|
1126
|
+
sleep_state = lst2group([i, each] for i, each in enumerate(sleep_state))
|
|
1127
|
+
|
|
1128
|
+
tick_step = self._choose_tick_step(self.show_duration)
|
|
1129
|
+
|
|
1130
|
+
for i, each in enumerate(self.show_idx):
|
|
1131
|
+
ax = self.signal_ax[i + 1]
|
|
1132
|
+
y_lim = self.y_lims[each]
|
|
1133
|
+
y_shift = self.y_shift[each]
|
|
1134
|
+
sf = self.midata.sf[each]
|
|
1135
|
+
|
|
1136
|
+
seg = self.midata.signals[each][
|
|
1137
|
+
int(self.current_sec * sf): int(
|
|
1138
|
+
(self.current_sec + self.show_duration) * sf)]
|
|
1139
|
+
# Short windows (up to ~3 min at 300 Hz) keep every sample;
|
|
1140
|
+
# long windows use a min/max envelope so the waveform keeps its
|
|
1141
|
+
# shape without the obvious thinning of every-Nth decimation.
|
|
1142
|
+
if len(seg) <= 60000:
|
|
1143
|
+
xs, ys = None, seg
|
|
1144
|
+
else:
|
|
1145
|
+
xs, ys = self._decimate_trace(seg)
|
|
1146
|
+
if xs is None:
|
|
1147
|
+
line, = ax.plot(ys, color=self._plot_trace, linewidth=0.5)
|
|
1148
|
+
else:
|
|
1149
|
+
line, = ax.plot(xs, ys, color=self._plot_trace, linewidth=0.5)
|
|
1150
|
+
self._signal_artists.setdefault(i + 1, []).append(line)
|
|
1151
|
+
ax.set_ylim(ymin=-y_lim + y_shift, ymax=y_lim + y_shift)
|
|
1152
|
+
ax.set_xlim(xmin=0, xmax=self.show_duration * sf)
|
|
1153
|
+
ax.xaxis.set_ticks([])
|
|
1154
|
+
ax.yaxis.set_ticks([])
|
|
1155
|
+
ax.set_ylabel(f"{self.midata.channels[each]}\n{y_lim:.2e}")
|
|
1156
|
+
|
|
1157
|
+
# grid lines: 5 s for short windows, tick step for long ones
|
|
1158
|
+
grid_step = 5 if self.show_duration < 300 else tick_step
|
|
1159
|
+
for pos_ in range(0, self.show_duration, grid_step):
|
|
1160
|
+
grid_line = ax.axvline(pos_ * sf, color=self._plot_grid,
|
|
1161
|
+
linestyle="--", linewidth=1, alpha=0.45)
|
|
1162
|
+
self._signal_artists[i + 1].append(grid_line)
|
|
1163
|
+
|
|
1164
|
+
# Sleep-state background: one rectangle per run (the fill spans
|
|
1165
|
+
# the full height, so two x-points are exact and cheap to draw)
|
|
1166
|
+
for state in sleep_state:
|
|
1167
|
+
fill = ax.fill_between(
|
|
1168
|
+
[int(state[0] * sf), int(state[1] * sf)],
|
|
1169
|
+
-y_lim + y_shift, y_lim + y_shift,
|
|
1170
|
+
facecolor=self.state_color_dict[state[2]],
|
|
1171
|
+
alpha=float(self.config["gui"]["statecolorbgalpha"]))
|
|
1172
|
+
self._signal_artists[i + 1].append(fill)
|
|
1173
|
+
|
|
1174
|
+
# Time ticks only on the last channel box (auto-reduced for long windows)
|
|
1175
|
+
last_sf = self.midata.sf[self.show_idx[-1]]
|
|
1176
|
+
self.signal_ax[-1].xaxis.set_ticks(
|
|
1177
|
+
[int(each * last_sf) for each in range(0, self.show_duration + 1, tick_step)],
|
|
1178
|
+
range(self.current_sec, self.current_sec + self.show_duration + 1, tick_step),
|
|
1179
|
+
rotation=45)
|
|
1180
|
+
if self.show_duration < 300:
|
|
1181
|
+
self.signal_ax[-1].xaxis.set_ticks(
|
|
1182
|
+
[int(each * last_sf) for each in range(0, self.show_duration + 1)],
|
|
1183
|
+
minor=True)
|
|
1184
|
+
|
|
1185
|
+
if self.StartEndRadio.isChecked():
|
|
1186
|
+
self.plot_start_end_line(flush=False, ms=True)
|
|
1187
|
+
if self.SleepStateRadio.isChecked():
|
|
1188
|
+
self.plot_start_end_line(flush=False)
|
|
1189
|
+
self.plot_marker_line(flush=False)
|
|
1190
|
+
self.plot_start_end_label_line(flush=False)
|
|
1191
|
+
self.plot_horizontal_line(flush=False)
|
|
1192
|
+
|
|
1193
|
+
# Keep the panels tightly packed (no gaps between boxes); the full
|
|
1194
|
+
# tight_layout runs on resize only (see _fit_canvases), not on every
|
|
1195
|
+
# page flip - that alone shaves ~20 ms off each flip.
|
|
1196
|
+
self.signal_figure.subplots_adjust(hspace=0)
|
|
1197
|
+
|
|
1198
|
+
if flush:
|
|
1199
|
+
self.signal_figure.canvas.draw()
|
|
1200
|
+
self.signal_figure.canvas.flush_events()
|
|
1201
|
+
if structure_changed:
|
|
1202
|
+
self._fit_canvases()
|
|
1203
|
+
self.signal_canvas.show()
|
|
1204
|
+
|
|
1205
|
+
def _apply_signal_layout(self):
|
|
1206
|
+
"""Tighten the signal figure layout so the panels touch each other."""
|
|
1207
|
+
try:
|
|
1208
|
+
self.signal_figure.tight_layout(h_pad=0, w_pad=0)
|
|
1209
|
+
self.signal_figure.subplots_adjust(hspace=0)
|
|
1210
|
+
except Exception as e: # pragma: no cover
|
|
1211
|
+
logger.debug(f"signal layout skipped: {e}")
|
|
1212
|
+
|
|
1213
|
+
def _style_signal_boxes(self):
|
|
1214
|
+
"""Give every signal panel a visible frame so the channels don't
|
|
1215
|
+
look like they float together (all four spines on, theme edge)."""
|
|
1216
|
+
edge = resolved_theme(
|
|
1217
|
+
self._theme_name, self._tone_name)["mpl"]["axes.edgecolor"]
|
|
1218
|
+
for ax in self.signal_ax:
|
|
1219
|
+
for spine in ax.spines.values():
|
|
1220
|
+
spine.set_visible(True)
|
|
1221
|
+
spine.set_edgecolor(edge)
|
|
1222
|
+
spine.set_linewidth(0.9)
|
|
1223
|
+
|
|
1224
|
+
@staticmethod
|
|
1225
|
+
def _decimate_trace(sig, bins=12000):
|
|
1226
|
+
"""Min/max envelope decimation that preserves the signal shape.
|
|
1227
|
+
|
|
1228
|
+
Naive every-Nth sampling makes dense EEG look visibly thinned and
|
|
1229
|
+
can alias rhythmic features; plotting the min/max of each bin keeps
|
|
1230
|
+
the waveform envelope intact while bounding the draw cost.
|
|
1231
|
+
"""
|
|
1232
|
+
n = len(sig)
|
|
1233
|
+
if n <= bins * 2:
|
|
1234
|
+
return np.arange(n), sig
|
|
1235
|
+
step = int(np.ceil(n / bins))
|
|
1236
|
+
n_bins = n // step
|
|
1237
|
+
trimmed = sig[: n_bins * step].reshape(n_bins, step)
|
|
1238
|
+
mn = trimmed.min(axis=1)
|
|
1239
|
+
mx = trimmed.max(axis=1)
|
|
1240
|
+
xs = np.repeat(np.arange(n_bins) * step, 2)
|
|
1241
|
+
ys = np.empty(2 * n_bins)
|
|
1242
|
+
ys[0::2] = mn
|
|
1243
|
+
ys[1::2] = mx
|
|
1244
|
+
return xs, ys
|
|
1245
|
+
|
|
1246
|
+
def replot_sleep_state_bg(self, state):
|
|
1247
|
+
"""Replot the sleep-state background of the selected start-end area."""
|
|
1248
|
+
replot_start = 0 if self.current_sec >= self.start_end[0] \
|
|
1249
|
+
else (self.start_end[0] - self.current_sec)
|
|
1250
|
+
replot_end = self.show_duration if (
|
|
1251
|
+
self.current_sec + self.show_duration <= self.start_end[1]
|
|
1252
|
+
) else (self.start_end[1] - self.current_sec)
|
|
1253
|
+
|
|
1254
|
+
for i, each in enumerate(self.show_idx):
|
|
1255
|
+
y_lim = self.y_lims[each]
|
|
1256
|
+
y_shift = self.y_shift[each]
|
|
1257
|
+
sf = self.midata.sf[each]
|
|
1258
|
+
x = range(int(replot_start * sf), int(replot_end * sf))
|
|
1259
|
+
cover = self.signal_ax[i + 1].fill_between(
|
|
1260
|
+
x, -y_lim + y_shift, y_lim + y_shift,
|
|
1261
|
+
facecolor=resolved_theme(
|
|
1262
|
+
self._theme_name, self._tone_name)["plot"]["bg"], alpha=1)
|
|
1263
|
+
fill = self.signal_ax[i + 1].fill_between(
|
|
1264
|
+
x, -y_lim + y_shift, y_lim + y_shift,
|
|
1265
|
+
facecolor=self.state_color_dict[state],
|
|
1266
|
+
alpha=float(self.config["gui"]["statecolorbgalpha"]))
|
|
1267
|
+
self._signal_artists.setdefault(i + 1, []).extend([cover, fill])
|
|
1268
|
+
self.signal_figure.canvas.draw()
|
|
1269
|
+
self.signal_figure.canvas.flush_events()
|
|
1270
|
+
|
|
1271
|
+
def spec_percentile_change(self):
|
|
1272
|
+
"""Triggered by the spectrogram percentile spin box."""
|
|
1273
|
+
self.spectrogram_percentile = self.PercentileSpin.value()
|
|
1274
|
+
self.plot_spectrogram(flush=True)
|
|
1275
|
+
|
|
1276
|
+
def default_ch4Spec(self):
|
|
1277
|
+
"""Set the selected channel as the default spectrogram channel."""
|
|
1278
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
1279
|
+
if len(selected_channel) != 1:
|
|
1280
|
+
QMessageBox.about(
|
|
1281
|
+
self, "Error",
|
|
1282
|
+
"Select one channel to be the default channel for spectrogram.")
|
|
1283
|
+
return
|
|
1284
|
+
self.current_spectrogram_idx = selected_channel[0]
|
|
1285
|
+
self.plot_spectrogram(flush=True)
|
|
1286
|
+
|
|
1287
|
+
def plot_spectrogram(self, flush=False):
|
|
1288
|
+
"""Redraw the spectrogram strip (cached whole-file STFT when possible)."""
|
|
1289
|
+
if self.midata is None:
|
|
1290
|
+
return
|
|
1291
|
+
from misleep.preprocessing.spectral import spectrogram
|
|
1292
|
+
# remove the previous spectrogram artist (cheaper than clearing axes)
|
|
1293
|
+
if getattr(self, "_spec_artist", None) is not None:
|
|
1294
|
+
try:
|
|
1295
|
+
self._spec_artist.remove()
|
|
1296
|
+
except Exception:
|
|
1297
|
+
pass
|
|
1298
|
+
self._spec_artist = None
|
|
1299
|
+
freq_range = [float(x) for x in self.config["gui"]["freq_range"].strip("[]").split(",")]
|
|
1300
|
+
ch = self.current_spectrogram_idx
|
|
1301
|
+
sf = self.midata.sf[ch]
|
|
1302
|
+
|
|
1303
|
+
# Cache the STFT of the whole file per channel; page flips then only
|
|
1304
|
+
# slice the cached spectrogram instead of recomputing it.
|
|
1305
|
+
if ch not in self._spec_full_cache:
|
|
1306
|
+
if self.midata.duration <= self._spec_cache_max_sec:
|
|
1307
|
+
f, t, Sxx = spectrogram(
|
|
1308
|
+
signal=self.midata.signals[ch], sf=sf,
|
|
1309
|
+
band=freq_range, step=1, win_sec=5, norm=True)
|
|
1310
|
+
self._spec_full_cache[ch] = (f, t, Sxx)
|
|
1311
|
+
else:
|
|
1312
|
+
self._spec_full_cache[ch] = None # too long: per-window
|
|
1313
|
+
|
|
1314
|
+
cached = self._spec_full_cache.get(ch)
|
|
1315
|
+
if cached is not None:
|
|
1316
|
+
f, t, Sxx = cached
|
|
1317
|
+
sel = (t >= self.current_sec) & (t <= self.current_sec + self.show_duration)
|
|
1318
|
+
t = t[sel]
|
|
1319
|
+
Sxx = Sxx[:, sel]
|
|
1320
|
+
else:
|
|
1321
|
+
f, t, Sxx = spectrogram(
|
|
1322
|
+
signal=self.midata.signals[ch][
|
|
1323
|
+
int(self.current_sec * sf): int(
|
|
1324
|
+
(self.current_sec + self.show_duration) * sf)],
|
|
1325
|
+
sf=sf, band=freq_range, step=1, win_sec=5, norm=True)
|
|
1326
|
+
|
|
1327
|
+
# Long windows: cap the number of time columns drawn
|
|
1328
|
+
if t.size > 2000:
|
|
1329
|
+
step_t = max(1, -(-t.size // 2000))
|
|
1330
|
+
t = t[::step_t]
|
|
1331
|
+
Sxx = Sxx[:, ::step_t]
|
|
1332
|
+
|
|
1333
|
+
cmap_name = self.config.get("gui", "spectrogram_cmap", fallback="jet")
|
|
1334
|
+
try:
|
|
1335
|
+
cmap = plt.get_cmap(cmap_name)
|
|
1336
|
+
except ValueError:
|
|
1337
|
+
cmap = plt.get_cmap("jet")
|
|
1338
|
+
vmin, vmax = spectrogram_color_limits(Sxx, self.spectrogram_percentile)
|
|
1339
|
+
|
|
1340
|
+
self.signal_ax[0].set_xticks([])
|
|
1341
|
+
# Pin the horizontal extent explicitly: with reused axes the
|
|
1342
|
+
# auto-limit can stay stuck at a previous (longer) window, which
|
|
1343
|
+
# compresses the strip to a fraction of its width.
|
|
1344
|
+
self.signal_ax[0].set_xlim(t.min(), t.max())
|
|
1345
|
+
self.signal_ax[0].set_ylim(freq_range)
|
|
1346
|
+
self.signal_ax[0].set_ylabel(f"{self.midata.channels[ch]}")
|
|
1347
|
+
# Match v2's dark low-power background even in the light theme and
|
|
1348
|
+
# eliminate axes-color seams around the pcolormesh.
|
|
1349
|
+
self.signal_ax[0].set_facecolor(cmap(0.0))
|
|
1350
|
+
self._spec_artist = self.signal_ax[0].pcolormesh(
|
|
1351
|
+
t, f, Sxx, cmap=cmap, vmin=vmin, vmax=vmax, shading="auto")
|
|
1352
|
+
|
|
1353
|
+
if flush:
|
|
1354
|
+
self.signal_figure.canvas.draw()
|
|
1355
|
+
self.signal_figure.canvas.flush_events()
|
|
1356
|
+
|
|
1357
|
+
def plot_start_end_line(self, flush=True, ms=False):
|
|
1358
|
+
"""Plot the interactive start/end selection lines in the signal area."""
|
|
1359
|
+
start_end = self.start_end_ms if ms else self.start_end
|
|
1360
|
+
for axvline in self.signal_start_end_axvline:
|
|
1361
|
+
try:
|
|
1362
|
+
axvline.remove()
|
|
1363
|
+
except Exception:
|
|
1364
|
+
pass
|
|
1365
|
+
self.signal_start_end_axvline = []
|
|
1366
|
+
for i, each in enumerate(start_end):
|
|
1367
|
+
if self.current_sec <= each <= self.current_sec + self.show_duration:
|
|
1368
|
+
for idx, show_ in enumerate(self.show_idx):
|
|
1369
|
+
y_lim = self.y_lims[show_]
|
|
1370
|
+
y_shift = self.y_shift[show_]
|
|
1371
|
+
sf = self.midata.sf[show_]
|
|
1372
|
+
if ms:
|
|
1373
|
+
x = round((each - self.current_sec) * sf, 3)
|
|
1374
|
+
else:
|
|
1375
|
+
x = int((each - self.current_sec) * sf)
|
|
1376
|
+
|
|
1377
|
+
if i in (0, 1):
|
|
1378
|
+
self.signal_start_end_axvline.append(
|
|
1379
|
+
self.signal_ax[idx + 1].axvline(x, color="lime", alpha=1))
|
|
1380
|
+
|
|
1381
|
+
if i == 0:
|
|
1382
|
+
self.signal_start_end_axvline.append(
|
|
1383
|
+
self.signal_ax[idx + 1].text(
|
|
1384
|
+
x=x, y=-y_lim + y_shift, s="S", color="lime"))
|
|
1385
|
+
if i == 1:
|
|
1386
|
+
self.signal_start_end_axvline.append(
|
|
1387
|
+
self.signal_ax[idx + 1].text(
|
|
1388
|
+
x=x, y=-y_lim + y_shift,
|
|
1389
|
+
horizontalalignment="right", s="E", color="lime"))
|
|
1390
|
+
|
|
1391
|
+
if flush:
|
|
1392
|
+
self.signal_figure.canvas.draw()
|
|
1393
|
+
self.signal_figure.canvas.flush_events()
|
|
1394
|
+
self.plot_hypo()
|
|
1395
|
+
|
|
1396
|
+
def plot_marker_line(self, flush=True):
|
|
1397
|
+
"""Plot marker lines in the signal area."""
|
|
1398
|
+
marker_color = self.config["gui"].get(
|
|
1399
|
+
"markerlinecolor", "red").strip("\"'")
|
|
1400
|
+
for axvline in self.signal_marker_axvline:
|
|
1401
|
+
try:
|
|
1402
|
+
axvline.remove()
|
|
1403
|
+
except Exception:
|
|
1404
|
+
pass
|
|
1405
|
+
self.signal_marker_axvline = []
|
|
1406
|
+
for each in self.mianno.marker:
|
|
1407
|
+
if self.current_sec <= each[0] <= self.current_sec + self.show_duration:
|
|
1408
|
+
for idx, show_ in enumerate(self.show_idx):
|
|
1409
|
+
self.signal_marker_axvline.append(
|
|
1410
|
+
self.signal_ax[idx + 1].axvline(
|
|
1411
|
+
int((each[0] - self.current_sec) * self.midata.sf[show_]),
|
|
1412
|
+
color=marker_color, alpha=1))
|
|
1413
|
+
self.signal_marker_axvline.append(
|
|
1414
|
+
self.signal_ax[1].text(
|
|
1415
|
+
x=int((each[0] - self.current_sec) * self.midata.sf[self.show_idx[0]]),
|
|
1416
|
+
y=self.y_lims[self.show_idx[0]] + self.y_shift[self.show_idx[0]],
|
|
1417
|
+
s=each[1], verticalalignment="top", color=marker_color))
|
|
1418
|
+
|
|
1419
|
+
if flush:
|
|
1420
|
+
self.signal_figure.canvas.draw()
|
|
1421
|
+
self.signal_figure.canvas.flush_events()
|
|
1422
|
+
self.plot_hypo()
|
|
1423
|
+
|
|
1424
|
+
def plot_start_end_label_line(self, flush=True):
|
|
1425
|
+
"""Plot the start/end annotation label lines."""
|
|
1426
|
+
for art in getattr(self, "signal_se_label_axvline", []):
|
|
1427
|
+
try:
|
|
1428
|
+
art.remove()
|
|
1429
|
+
except Exception:
|
|
1430
|
+
pass
|
|
1431
|
+
self.signal_se_label_axvline = []
|
|
1432
|
+
for each in self.mianno.start_end:
|
|
1433
|
+
if self.current_sec <= each[0] <= self.current_sec + self.show_duration:
|
|
1434
|
+
for idx, show_ in enumerate(self.show_idx):
|
|
1435
|
+
self.signal_se_label_axvline.append(
|
|
1436
|
+
self.signal_ax[idx + 1].axvline(
|
|
1437
|
+
int((each[0] - self.current_sec) * self.midata.sf[show_]),
|
|
1438
|
+
color=identify_startend_color(self.start_end_color_dict, each[2]),
|
|
1439
|
+
alpha=1))
|
|
1440
|
+
self.signal_se_label_axvline.append(
|
|
1441
|
+
self.signal_ax[1].text(
|
|
1442
|
+
x=int((each[0] - self.current_sec) * self.midata.sf[self.show_idx[0]]),
|
|
1443
|
+
y=self.y_lims[self.show_idx[0]] + self.y_shift[self.show_idx[0]],
|
|
1444
|
+
s=each[2] + "-S", verticalalignment="top",
|
|
1445
|
+
color=identify_startend_color(self.start_end_color_dict, each[2])))
|
|
1446
|
+
|
|
1447
|
+
if self.current_sec <= each[1] <= self.current_sec + self.show_duration:
|
|
1448
|
+
for idx, show_ in enumerate(self.show_idx):
|
|
1449
|
+
self.signal_se_label_axvline.append(
|
|
1450
|
+
self.signal_ax[idx + 1].axvline(
|
|
1451
|
+
int((each[1] - self.current_sec) * self.midata.sf[show_]),
|
|
1452
|
+
color="orange", alpha=1))
|
|
1453
|
+
self.signal_se_label_axvline.append(
|
|
1454
|
+
self.signal_ax[1].text(
|
|
1455
|
+
x=int((each[1] - self.current_sec) * self.midata.sf[self.show_idx[0]]),
|
|
1456
|
+
y=self.y_lims[self.show_idx[0]] + self.y_shift[self.show_idx[0]],
|
|
1457
|
+
s=each[2] + "-E", verticalalignment="top",
|
|
1458
|
+
horizontalalignment="right", color="orange"))
|
|
1459
|
+
|
|
1460
|
+
if flush:
|
|
1461
|
+
self.signal_figure.canvas.draw()
|
|
1462
|
+
self.signal_figure.canvas.flush_events()
|
|
1463
|
+
|
|
1464
|
+
def plot_horizontal_line(self, flush=True):
|
|
1465
|
+
"""Plot the horizontal reference lines."""
|
|
1466
|
+
for axhline in self.axhline_horizontal:
|
|
1467
|
+
try:
|
|
1468
|
+
axhline.remove()
|
|
1469
|
+
except Exception:
|
|
1470
|
+
pass
|
|
1471
|
+
for idx, show_ in enumerate(self.show_idx):
|
|
1472
|
+
ch = self.midata.channels[show_]
|
|
1473
|
+
sf = self.midata.sf[show_]
|
|
1474
|
+
for line_value, color, comment in self.horizontal_line.get(ch, []):
|
|
1475
|
+
self.axhline_horizontal.append(
|
|
1476
|
+
self.signal_ax[idx + 1].axhline(line_value, color=color, alpha=1))
|
|
1477
|
+
self.axhline_horizontal.append(
|
|
1478
|
+
self.signal_ax[idx + 1].text(
|
|
1479
|
+
x=int(self.show_duration * sf), y=line_value,
|
|
1480
|
+
s=f"{line_value:.2e}",
|
|
1481
|
+
horizontalalignment="left", color=color))
|
|
1482
|
+
|
|
1483
|
+
if flush:
|
|
1484
|
+
self.signal_figure.canvas.draw()
|
|
1485
|
+
self.signal_figure.canvas.flush_events()
|
|
1486
|
+
|
|
1487
|
+
def plot_hypo(self):
|
|
1488
|
+
"""Redraw the hypnogram area (one colored segment per sleep state)."""
|
|
1489
|
+
# The state-step background only changes when the annotation changes;
|
|
1490
|
+
# rebuild it lazily and reuse it across page flips.
|
|
1491
|
+
key = None
|
|
1492
|
+
if self.mianno is not None:
|
|
1493
|
+
key = (
|
|
1494
|
+
id(self.mianno),
|
|
1495
|
+
self._hypo_revision,
|
|
1496
|
+
tuple(sorted(self.state_map_dict.items())),
|
|
1497
|
+
tuple(sorted(self.state_color_dict.items())),
|
|
1498
|
+
float(self.config["gui"].get("hypnogramstatealpha", "0.55")),
|
|
1499
|
+
)
|
|
1500
|
+
if key != self._hypo_key:
|
|
1501
|
+
self.hypo_ax.clear()
|
|
1502
|
+
self._hypo_transient = []
|
|
1503
|
+
# One thick filled bar per consecutive run of the same state,
|
|
1504
|
+
# spanning the full state band (edges touch between states).
|
|
1505
|
+
# All bars are drawn as a single PolyCollection, so building the
|
|
1506
|
+
# hypnogram stays fast even for multi-hour recordings with tens
|
|
1507
|
+
# of thousands of state runs.
|
|
1508
|
+
band = 0.5 # half height of each bar (state +/- band)
|
|
1509
|
+
runs = lst2group([i, each] for i, each in enumerate(self.mianno.sleep_state))
|
|
1510
|
+
rects = []
|
|
1511
|
+
colors = []
|
|
1512
|
+
for start, end, state in runs:
|
|
1513
|
+
if end <= start:
|
|
1514
|
+
continue
|
|
1515
|
+
y0 = state - band
|
|
1516
|
+
y1 = state + band
|
|
1517
|
+
rects.append([(start, y0), (end, y0), (end, y1), (start, y1)])
|
|
1518
|
+
colors.append(self.state_color_dict.get(state, "#8892a0"))
|
|
1519
|
+
if rects:
|
|
1520
|
+
collection = PolyCollection(
|
|
1521
|
+
rects, facecolors=colors, edgecolors="none",
|
|
1522
|
+
alpha=float(self.config["gui"].get(
|
|
1523
|
+
"hypnogramstatealpha", "0.55")), zorder=1)
|
|
1524
|
+
self._hypo_steps.append(self.hypo_ax.add_collection(collection))
|
|
1525
|
+
|
|
1526
|
+
self.hypo_ax.set_ylim(0, len(list(self.state_map_dict.keys())) + 0.5)
|
|
1527
|
+
self.hypo_ax.set_xlim(0, self.total_seconds)
|
|
1528
|
+
self.hypo_ax.yaxis.set_ticks(
|
|
1529
|
+
list(self.state_map_dict.keys()), list(self.state_map_dict.values()))
|
|
1530
|
+
self.hypo_ax.set_xlabel("Time (s)")
|
|
1531
|
+
# faint horizontal guides at each state level
|
|
1532
|
+
self.hypo_ax.grid(axis="y", alpha=0.25)
|
|
1533
|
+
self._hypo_key = key
|
|
1534
|
+
|
|
1535
|
+
# remove the per-flip overlay artists (current-time / event lines)
|
|
1536
|
+
for art in self._hypo_transient:
|
|
1537
|
+
try:
|
|
1538
|
+
art.remove()
|
|
1539
|
+
except Exception:
|
|
1540
|
+
pass
|
|
1541
|
+
self._hypo_transient = []
|
|
1542
|
+
|
|
1543
|
+
try:
|
|
1544
|
+
self.hypo_axvline.remove()
|
|
1545
|
+
except Exception:
|
|
1546
|
+
pass # axes may have been recreated; a new line is drawn below
|
|
1547
|
+
self.hypo_axvline = self.hypo_ax.axvline(
|
|
1548
|
+
self.current_sec, color="gray", alpha=0.8)
|
|
1549
|
+
self._hypo_transient.append(self.hypo_axvline)
|
|
1550
|
+
|
|
1551
|
+
if self.StartEndRadio.isChecked():
|
|
1552
|
+
for each in self.start_end_ms:
|
|
1553
|
+
self._hypo_transient.append(
|
|
1554
|
+
self.hypo_ax.axvline(each, color="lime", alpha=1))
|
|
1555
|
+
if self.SleepStateRadio.isChecked():
|
|
1556
|
+
for each in self.start_end:
|
|
1557
|
+
self._hypo_transient.append(
|
|
1558
|
+
self.hypo_ax.axvline(each, color="lime", alpha=1))
|
|
1559
|
+
for each in self.mianno.marker:
|
|
1560
|
+
self._hypo_transient.append(
|
|
1561
|
+
self.hypo_ax.axvline(
|
|
1562
|
+
each[0], color=self.config["gui"].get(
|
|
1563
|
+
"markerlinecolor", "red").strip("\"'"), alpha=1,
|
|
1564
|
+
linewidth=1.6, zorder=6))
|
|
1565
|
+
|
|
1566
|
+
self.hypo_figure.canvas.draw()
|
|
1567
|
+
self.hypo_figure.canvas.flush_events()
|
|
1568
|
+
|
|
1569
|
+
def redraw_all(self, second=0):
|
|
1570
|
+
"""Validate ``second`` and redraw everything."""
|
|
1571
|
+
# heal the canvases first in case an export closed a figure
|
|
1572
|
+
self._repair_canvases()
|
|
1573
|
+
|
|
1574
|
+
if second + self.show_duration >= self.total_seconds:
|
|
1575
|
+
self.current_sec = self.total_seconds - self.show_duration
|
|
1576
|
+
elif second <= 0:
|
|
1577
|
+
self.current_sec = 0
|
|
1578
|
+
else:
|
|
1579
|
+
self.current_sec = second
|
|
1580
|
+
|
|
1581
|
+
# Block signals to avoid cyclic value-change operations
|
|
1582
|
+
self.ScrollerBar.blockSignals(True)
|
|
1583
|
+
self.SecondSpin.blockSignals(True)
|
|
1584
|
+
self.DateTimeEdit.blockSignals(True)
|
|
1585
|
+
self.ScrollerBar.setValue(self.current_sec)
|
|
1586
|
+
self.SecondSpin.setValue(self.current_sec)
|
|
1587
|
+
self.DateTimeEdit.setDateTime(self.ac_time + datetime.timedelta(seconds=self.current_sec))
|
|
1588
|
+
self.ScrollerBar.blockSignals(False)
|
|
1589
|
+
self.SecondSpin.blockSignals(False)
|
|
1590
|
+
self.DateTimeEdit.blockSignals(False)
|
|
1591
|
+
self.plot_signals()
|
|
1592
|
+
self.plot_hypo()
|
|
1593
|
+
|
|
1594
|
+
def fill_channel_listView(self):
|
|
1595
|
+
"""Fill the channel list view with ``self.midata.channels``."""
|
|
1596
|
+
self._updating_ch_list = True
|
|
1597
|
+
try:
|
|
1598
|
+
self.channel_slm.setChannels(self.midata.channels)
|
|
1599
|
+
# only (re)attach the model when needed, otherwise the
|
|
1600
|
+
# selection would be cleared on every refresh
|
|
1601
|
+
if self.ChListView.model() is not self.channel_slm:
|
|
1602
|
+
self.ChListView.setModel(self.channel_slm)
|
|
1603
|
+
selection_model = self.ChListView.selectionModel()
|
|
1604
|
+
if selection_model is not None:
|
|
1605
|
+
selection_model.selectionChanged.connect(
|
|
1606
|
+
self._update_channel_action_states)
|
|
1607
|
+
finally:
|
|
1608
|
+
self._updating_ch_list = False
|
|
1609
|
+
self._update_channel_action_states()
|
|
1610
|
+
|
|
1611
|
+
def _on_channel_list_changed(self, *args):
|
|
1612
|
+
"""React to any channel-list model change.
|
|
1613
|
+
|
|
1614
|
+
``ChannelListModel`` supports ``moveRows``, so a drag & drop emits
|
|
1615
|
+
``rowsMoved``; this hook detects the resulting pure reorder (a
|
|
1616
|
+
permutation of the channel names) and applies it. Plain text edits
|
|
1617
|
+
are left to :meth:`channel_rename`.
|
|
1618
|
+
"""
|
|
1619
|
+
if self._updating_ch_list or self.midata is None:
|
|
1620
|
+
return
|
|
1621
|
+
try:
|
|
1622
|
+
lst = list(self.channel_slm.channels())
|
|
1623
|
+
except Exception:
|
|
1624
|
+
return
|
|
1625
|
+
if len(lst) != self.midata.n_channels:
|
|
1626
|
+
return
|
|
1627
|
+
if lst == self.midata.channels:
|
|
1628
|
+
return
|
|
1629
|
+
if sorted(lst) != sorted(self.midata.channels):
|
|
1630
|
+
return # a text edit (rename), not a move
|
|
1631
|
+
self.channel_moved(None, 0, len(lst) - 1, None, 0)
|
|
1632
|
+
|
|
1633
|
+
def _apply_channel_order(self, new_order):
|
|
1634
|
+
"""Reorder the data and view state to match ``new_order``.
|
|
1635
|
+
|
|
1636
|
+
Only the order changes -- channel names and data stay intact.
|
|
1637
|
+
"""
|
|
1638
|
+
spec_name = self.midata.channels[self.current_spectrogram_idx]
|
|
1639
|
+
order_idx = [self.midata.channels.index(name) for name in new_order]
|
|
1640
|
+
|
|
1641
|
+
self.midata.reorder_channels(new_order)
|
|
1642
|
+
|
|
1643
|
+
self.y_lims = [self.y_lims[i] for i in order_idx]
|
|
1644
|
+
self.y_shift = [self.y_shift[i] for i in order_idx]
|
|
1645
|
+
self.horizontal_line = {name: self.horizontal_line.get(name, []) for name in new_order}
|
|
1646
|
+
self.current_spectrogram_idx = self.midata.channels.index(spec_name)
|
|
1647
|
+
self.show_idx = list(range(self.midata.n_channels))
|
|
1648
|
+
|
|
1649
|
+
self.fill_channel_listView()
|
|
1650
|
+
self.redraw_all(second=self.current_sec)
|
|
1651
|
+
|
|
1652
|
+
def channel_moved(self, parent, start, end, destination, row):
|
|
1653
|
+
"""Reorder the data to match a model move in the channel list."""
|
|
1654
|
+
if self._updating_ch_list or self.midata is None:
|
|
1655
|
+
return
|
|
1656
|
+
|
|
1657
|
+
new_order = list(self.channel_slm.channels())
|
|
1658
|
+
if new_order == self.midata.channels or len(new_order) != self.midata.n_channels:
|
|
1659
|
+
return
|
|
1660
|
+
self._apply_channel_order(new_order)
|
|
1661
|
+
|
|
1662
|
+
def move_channel(self, direction):
|
|
1663
|
+
"""Move the selected channel up or down in the list (buttons)."""
|
|
1664
|
+
if self.midata is None:
|
|
1665
|
+
return
|
|
1666
|
+
selected = [r.row() for r in self.ChListView.selectedIndexes()]
|
|
1667
|
+
if len(selected) != 1:
|
|
1668
|
+
return
|
|
1669
|
+
row = selected[0]
|
|
1670
|
+
target = row - 1 if direction == "up" else row + 1
|
|
1671
|
+
if not (0 <= target < self.midata.n_channels):
|
|
1672
|
+
return
|
|
1673
|
+
|
|
1674
|
+
order = list(self.midata.channels)
|
|
1675
|
+
item = order.pop(row)
|
|
1676
|
+
order.insert(target, item)
|
|
1677
|
+
self._apply_channel_order(order)
|
|
1678
|
+
|
|
1679
|
+
# keep the moved item selected (get a fresh selection model --
|
|
1680
|
+
# setModel may have recreated it)
|
|
1681
|
+
sm = self.ChListView.selectionModel()
|
|
1682
|
+
idx = self.channel_slm.index(target)
|
|
1683
|
+
self.ChListView.setCurrentIndex(idx)
|
|
1684
|
+
if sm is not None:
|
|
1685
|
+
sm.select(idx, sm.SelectionFlag.ClearAndSelect)
|
|
1686
|
+
self._update_channel_action_states()
|
|
1687
|
+
|
|
1688
|
+
def show_marker_list(self):
|
|
1689
|
+
"""Open the marker list viewer dialog."""
|
|
1690
|
+
if self.mianno is None:
|
|
1691
|
+
QMessageBox.about(self, "Info", "Load data and annotation first.")
|
|
1692
|
+
return
|
|
1693
|
+
dialog = EventListDialog(self)
|
|
1694
|
+
dialog.show_events(kind="marker")
|
|
1695
|
+
dialog.exec()
|
|
1696
|
+
|
|
1697
|
+
def show_start_end_list(self):
|
|
1698
|
+
"""Open the start-end event list viewer dialog."""
|
|
1699
|
+
if self.mianno is None:
|
|
1700
|
+
QMessageBox.about(self, "Info", "Load data and annotation first.")
|
|
1701
|
+
return
|
|
1702
|
+
dialog = EventListDialog(self)
|
|
1703
|
+
dialog.show_events(kind="start_end")
|
|
1704
|
+
dialog.exec()
|
|
1705
|
+
|
|
1706
|
+
def _sync_state_buttons(self):
|
|
1707
|
+
"""Keep the Annotation dock state buttons in sync with the state map.
|
|
1708
|
+
|
|
1709
|
+
Every configured state lives in the same two-column grid. The four
|
|
1710
|
+
original buttons are reused; additional states are created there too.
|
|
1711
|
+
"""
|
|
1712
|
+
orig = {1: self.NREMBt, 2: self.REMBt, 3: self.WakeBt, 4: self.InitBt}
|
|
1713
|
+
self._state_btns = {}
|
|
1714
|
+
for bt in orig.values():
|
|
1715
|
+
bt.hide()
|
|
1716
|
+
|
|
1717
|
+
# clear and (re)fill the shared state grid
|
|
1718
|
+
extra_grid = self.ExtraStatePanel.layout()
|
|
1719
|
+
while extra_grid.count():
|
|
1720
|
+
item = extra_grid.takeAt(0)
|
|
1721
|
+
w = item.widget()
|
|
1722
|
+
if w is not None and w not in orig.values():
|
|
1723
|
+
w.deleteLater()
|
|
1724
|
+
|
|
1725
|
+
for button_idx, code in enumerate(sorted(self.state_map_dict)):
|
|
1726
|
+
name = self.state_map_dict[code]
|
|
1727
|
+
color = self.state_color_dict.get(code, "#808080")
|
|
1728
|
+
if code in orig:
|
|
1729
|
+
bt = orig[code]
|
|
1730
|
+
bt.show()
|
|
1731
|
+
else:
|
|
1732
|
+
bt = QPushButton()
|
|
1733
|
+
bt.clicked.connect(
|
|
1734
|
+
lambda _=False, c=code: self.sleep_state_label(state_code=c))
|
|
1735
|
+
row, col = divmod(button_idx, 2)
|
|
1736
|
+
extra_grid.addWidget(bt, row, col)
|
|
1737
|
+
bt.setSizePolicy(QSizePolicy.Policy.Expanding,
|
|
1738
|
+
QSizePolicy.Policy.Fixed)
|
|
1739
|
+
bt.setText(f"{code}:{name}")
|
|
1740
|
+
# Full local style: readable text on any state color (works in
|
|
1741
|
+
# light and dark themes - e.g. a white 'Init' button needs dark
|
|
1742
|
+
# text, and a dark state color needs white text).
|
|
1743
|
+
fg = _text_on(color)
|
|
1744
|
+
bt.setStyleSheet(
|
|
1745
|
+
f"QPushButton {{ background-color: {color}; color: {fg};"
|
|
1746
|
+
f" border: 1px solid rgba(0,0,0,150); border-radius: 3px;"
|
|
1747
|
+
f" padding: 2px 7px; min-height: 19px; max-height: 22px;"
|
|
1748
|
+
f" font-weight: 600; }}"
|
|
1749
|
+
f" QPushButton:hover {{ border: 1px solid rgba(255,255,255,190); }}"
|
|
1750
|
+
f" QPushButton:pressed {{ background-color: {color}; }}"
|
|
1751
|
+
f" QPushButton:disabled {{ background-color: #9aa3af;"
|
|
1752
|
+
f" color: #dce1e8; border-color: #7d8794; }}")
|
|
1753
|
+
bt.setToolTip(
|
|
1754
|
+
f"Label the selected area as {name} "
|
|
1755
|
+
f"(shortcut key {code if code < 10 else 0})")
|
|
1756
|
+
self._state_btns[code] = bt
|
|
1757
|
+
extra_grid.setColumnStretch(0, 1)
|
|
1758
|
+
extra_grid.setColumnStretch(1, 1)
|
|
1759
|
+
self._update_annotation_mode()
|
|
1760
|
+
|
|
1761
|
+
# ------------------------------------------------------------------
|
|
1762
|
+
# Interaction
|
|
1763
|
+
# ------------------------------------------------------------------
|
|
1764
|
+
def radio_recheck(self, radioBt):
|
|
1765
|
+
"""Reset the selection lines when the label mode changes."""
|
|
1766
|
+
if not radioBt.isChecked():
|
|
1767
|
+
return
|
|
1768
|
+
if radioBt is self.SleepStateRadio:
|
|
1769
|
+
self.start_end_ms = []
|
|
1770
|
+
self.plot_start_end_line()
|
|
1771
|
+
if radioBt is self.StartEndRadio:
|
|
1772
|
+
self.start_end = []
|
|
1773
|
+
self.plot_start_end_line(ms=True)
|
|
1774
|
+
self._update_annotation_mode()
|
|
1775
|
+
|
|
1776
|
+
def click_signal(self, event):
|
|
1777
|
+
"""Handle clicks in the signal area (marker / start-end selection).
|
|
1778
|
+
|
|
1779
|
+
The clicked axes identifies the channel (row ``i`` of the signal
|
|
1780
|
+
boxes); the x position gives the time.
|
|
1781
|
+
"""
|
|
1782
|
+
if event.inaxes == self.signal_ax[0]:
|
|
1783
|
+
return
|
|
1784
|
+
|
|
1785
|
+
# Find the channel row by identity (Axes are not compared by value)
|
|
1786
|
+
row = None
|
|
1787
|
+
for idx, ax in enumerate(self.signal_ax):
|
|
1788
|
+
if ax is event.inaxes:
|
|
1789
|
+
row = idx - 1
|
|
1790
|
+
break
|
|
1791
|
+
if row is None or row < 0 or row >= len(self.show_idx):
|
|
1792
|
+
return
|
|
1793
|
+
|
|
1794
|
+
try:
|
|
1795
|
+
sf = self.midata.sf[self.show_idx[row]]
|
|
1796
|
+
sec = int(event.xdata / sf) + self.current_sec
|
|
1797
|
+
except (TypeError, IndexError):
|
|
1798
|
+
return
|
|
1799
|
+
|
|
1800
|
+
if self.SleepStateRadio.isChecked():
|
|
1801
|
+
if event.button == 3:
|
|
1802
|
+
# Right click: remove the line(s) at this second
|
|
1803
|
+
for each in self.mianno.start_end:
|
|
1804
|
+
if each[0] == sec or each[1] == sec:
|
|
1805
|
+
self.mianno.start_end.remove(each)
|
|
1806
|
+
self.plot_signals()
|
|
1807
|
+
return
|
|
1808
|
+
if len(self.start_end) == 0:
|
|
1809
|
+
return
|
|
1810
|
+
if len(self.start_end) >= 1 and sec == self.start_end[0]:
|
|
1811
|
+
self.start_end = []
|
|
1812
|
+
self.plot_start_end_line()
|
|
1813
|
+
return
|
|
1814
|
+
if len(self.start_end) == 2 and sec + 1 == self.start_end[1]:
|
|
1815
|
+
self.start_end.pop(1)
|
|
1816
|
+
self.plot_start_end_line()
|
|
1817
|
+
return
|
|
1818
|
+
return
|
|
1819
|
+
|
|
1820
|
+
if not self.start_end:
|
|
1821
|
+
self.start_end.append(sec)
|
|
1822
|
+
elif len(self.start_end) == 2:
|
|
1823
|
+
self.start_end = [sec]
|
|
1824
|
+
elif sec < self.start_end[0]:
|
|
1825
|
+
self.start_end = [sec]
|
|
1826
|
+
else:
|
|
1827
|
+
self.start_end.append(sec + 1)
|
|
1828
|
+
|
|
1829
|
+
self.plot_start_end_line()
|
|
1830
|
+
|
|
1831
|
+
if self.MarkerRadio.isChecked():
|
|
1832
|
+
if event.button == 3:
|
|
1833
|
+
for each in self.mianno.marker:
|
|
1834
|
+
if abs(each[0] - sec) <= 1:
|
|
1835
|
+
self.mianno.marker.remove(each)
|
|
1836
|
+
self.plot_signals()
|
|
1837
|
+
self.plot_hypo()
|
|
1838
|
+
return
|
|
1839
|
+
|
|
1840
|
+
x = round(event.xdata / sf, 3) + self.current_sec
|
|
1841
|
+
|
|
1842
|
+
self.label_dialog._type = 0
|
|
1843
|
+
self.label_dialog.show_contents()
|
|
1844
|
+
self.label_dialog.exec()
|
|
1845
|
+
if self.label_dialog.closed:
|
|
1846
|
+
return
|
|
1847
|
+
|
|
1848
|
+
label_name = self.label_dialog.label_name
|
|
1849
|
+
self.mianno.marker.append([x, label_name])
|
|
1850
|
+
self.plot_marker_line()
|
|
1851
|
+
|
|
1852
|
+
self.is_saved = False
|
|
1853
|
+
self.AnnotationPathLabel.setText("*Annotation path:")
|
|
1854
|
+
|
|
1855
|
+
if self.StartEndRadio.isChecked():
|
|
1856
|
+
x = round(event.xdata / sf, 3) + self.current_sec
|
|
1857
|
+
if event.button == 3:
|
|
1858
|
+
for each in self.mianno.start_end:
|
|
1859
|
+
if int(each[0]) == sec or int(each[1]) == sec:
|
|
1860
|
+
self.mianno.start_end.remove(each)
|
|
1861
|
+
self.plot_signals()
|
|
1862
|
+
return
|
|
1863
|
+
if len(self.start_end_ms) == 0:
|
|
1864
|
+
return
|
|
1865
|
+
if len(self.start_end_ms) >= 1 and sec == int(self.start_end_ms[0]):
|
|
1866
|
+
self.start_end_ms = []
|
|
1867
|
+
self.plot_start_end_line(ms=True)
|
|
1868
|
+
return
|
|
1869
|
+
if len(self.start_end_ms) == 2 and sec == int(self.start_end_ms[1]):
|
|
1870
|
+
self.start_end_ms.pop(1)
|
|
1871
|
+
self.plot_start_end_line(ms=True)
|
|
1872
|
+
return
|
|
1873
|
+
return
|
|
1874
|
+
|
|
1875
|
+
if not self.start_end_ms:
|
|
1876
|
+
self.start_end_ms.append(x)
|
|
1877
|
+
elif len(self.start_end_ms) == 2:
|
|
1878
|
+
if abs(x - self.start_end_ms[1]) <= 1:
|
|
1879
|
+
self.start_end_ms[0] = self.start_end_ms[1]
|
|
1880
|
+
self.start_end_ms.pop(1)
|
|
1881
|
+
else:
|
|
1882
|
+
self.start_end_ms = [x]
|
|
1883
|
+
else:
|
|
1884
|
+
if x < self.start_end_ms[0]:
|
|
1885
|
+
QMessageBox.about(self, "Error", "End should be larger than Start!")
|
|
1886
|
+
return
|
|
1887
|
+
self.start_end_ms.append(x)
|
|
1888
|
+
|
|
1889
|
+
self.plot_start_end_line(ms=True)
|
|
1890
|
+
if len(self.start_end_ms) == 2:
|
|
1891
|
+
# Leave the matplotlib callback first, then open the modal
|
|
1892
|
+
# label chooser. This avoids a half-painted selection line.
|
|
1893
|
+
QTimer.singleShot(0, self.append_start_end)
|
|
1894
|
+
|
|
1895
|
+
def click_hypo(self, event):
|
|
1896
|
+
"""Jump to the clicked time on the hypnogram."""
|
|
1897
|
+
if event.inaxes is not self.hypo_ax or event.xdata is None:
|
|
1898
|
+
return
|
|
1899
|
+
current_sec = int(event.xdata)
|
|
1900
|
+
self.redraw_all(second=current_sec)
|
|
1901
|
+
|
|
1902
|
+
def scroller_change(self):
|
|
1903
|
+
"""Scrollbar value changed."""
|
|
1904
|
+
self.ScrollerBar.setDisabled(True)
|
|
1905
|
+
current_sec = self.ScrollerBar.value()
|
|
1906
|
+
self.redraw_all(second=current_sec)
|
|
1907
|
+
self.ScrollerBar.setEnabled(True)
|
|
1908
|
+
|
|
1909
|
+
def next_epoch(self):
|
|
1910
|
+
self.redraw_all(second=self.current_sec + 5)
|
|
1911
|
+
|
|
1912
|
+
def previous_epoch(self):
|
|
1913
|
+
self.redraw_all(second=self.current_sec - 5)
|
|
1914
|
+
|
|
1915
|
+
def next_page(self):
|
|
1916
|
+
self.redraw_all(second=self.current_sec + self.show_duration)
|
|
1917
|
+
|
|
1918
|
+
def previous_page(self):
|
|
1919
|
+
self.redraw_all(second=self.current_sec - self.show_duration)
|
|
1920
|
+
|
|
1921
|
+
def SecondSpin_change(self):
|
|
1922
|
+
self.redraw_all(second=self.SecondSpin.value())
|
|
1923
|
+
|
|
1924
|
+
def DateTimeEdit_change(self):
|
|
1925
|
+
dateTime = self.DateTimeEdit.dateTime().toPython()
|
|
1926
|
+
current_sec = int((dateTime - self.ac_time).total_seconds())
|
|
1927
|
+
self.redraw_all(second=current_sec)
|
|
1928
|
+
|
|
1929
|
+
# ------------------------------------------------------------------
|
|
1930
|
+
# Channel operations
|
|
1931
|
+
# ------------------------------------------------------------------
|
|
1932
|
+
def channel_rename(self):
|
|
1933
|
+
"""Rename channels from the list view.
|
|
1934
|
+
|
|
1935
|
+
A pure reorder (a permutation of the names) is not a rename -- it
|
|
1936
|
+
is handled by :meth:`_on_channel_list_changed` / :meth:`channel_moved`.
|
|
1937
|
+
"""
|
|
1938
|
+
new_channels = list(self.channel_slm.channels())
|
|
1939
|
+
if sorted(new_channels) == sorted(self.midata.channels):
|
|
1940
|
+
return # drag & drop reorder, not a rename
|
|
1941
|
+
|
|
1942
|
+
old_channels = list(self.midata.channels)
|
|
1943
|
+
rename_map = {}
|
|
1944
|
+
for old, new in zip(old_channels, new_channels):
|
|
1945
|
+
if old != new:
|
|
1946
|
+
rename_map[old] = new
|
|
1947
|
+
if not rename_map:
|
|
1948
|
+
return
|
|
1949
|
+
|
|
1950
|
+
# rename_channels may add a "_1" suffix when the target name is
|
|
1951
|
+
# already in use, so rebuild horizontal_line by position afterwards.
|
|
1952
|
+
self.midata.rename_channels(rename_map)
|
|
1953
|
+
final_channels = self.midata.channels
|
|
1954
|
+
self.horizontal_line = {
|
|
1955
|
+
final_channels[i]: self.horizontal_line.get(old_channels[i], [])
|
|
1956
|
+
for i in range(len(final_channels))
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
self.fill_channel_listView()
|
|
1960
|
+
self.plot_signals()
|
|
1961
|
+
|
|
1962
|
+
def show_chs(self):
|
|
1963
|
+
"""Show the selected channels."""
|
|
1964
|
+
selected_channels = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
1965
|
+
if not selected_channels:
|
|
1966
|
+
return
|
|
1967
|
+
self.show_idx = sorted(selected_channels)
|
|
1968
|
+
self.plot_signals()
|
|
1969
|
+
|
|
1970
|
+
def hide_chs(self):
|
|
1971
|
+
"""Hide the selected channels."""
|
|
1972
|
+
selected_channels = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
1973
|
+
if not selected_channels:
|
|
1974
|
+
return
|
|
1975
|
+
all_channels = copy.deepcopy(self.show_idx)
|
|
1976
|
+
for each in selected_channels:
|
|
1977
|
+
if each in all_channels:
|
|
1978
|
+
all_channels.remove(each)
|
|
1979
|
+
|
|
1980
|
+
if not all_channels:
|
|
1981
|
+
QMessageBox.about(self, "Error", "You can't hide all channels!")
|
|
1982
|
+
return
|
|
1983
|
+
|
|
1984
|
+
self.show_idx = all_channels
|
|
1985
|
+
self.plot_signals()
|
|
1986
|
+
|
|
1987
|
+
def delete_chs(self):
|
|
1988
|
+
"""Delete the selected channels from the data."""
|
|
1989
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
1990
|
+
if not selected_channel:
|
|
1991
|
+
return
|
|
1992
|
+
box = QMessageBox.question(
|
|
1993
|
+
self, "Warning", "You are deleting data!",
|
|
1994
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1995
|
+
QMessageBox.StandardButton.No)
|
|
1996
|
+
|
|
1997
|
+
if box == QMessageBox.StandardButton.Yes:
|
|
1998
|
+
if len(selected_channel) == self.midata.n_channels:
|
|
1999
|
+
QMessageBox.about(self, "Error", "You can't delete all channels!")
|
|
2000
|
+
return
|
|
2001
|
+
|
|
2002
|
+
spec_name = self.midata.channels[self.current_spectrogram_idx]
|
|
2003
|
+
for each in sorted(selected_channel, reverse=True):
|
|
2004
|
+
self.y_lims[each] = -1
|
|
2005
|
+
self.y_shift[each] = -1
|
|
2006
|
+
self.horizontal_line.pop(self.midata.channels[each], None)
|
|
2007
|
+
self.midata.delete(self.midata.channels[each])
|
|
2008
|
+
|
|
2009
|
+
self.y_lims = [each for each in self.y_lims if each != -1]
|
|
2010
|
+
self.y_shift = [each for each in self.y_shift if each != -1]
|
|
2011
|
+
if spec_name in self.midata.channels:
|
|
2012
|
+
self.current_spectrogram_idx = self.midata.channels.index(spec_name)
|
|
2013
|
+
else:
|
|
2014
|
+
self.current_spectrogram_idx = 0
|
|
2015
|
+
|
|
2016
|
+
self.show_idx = list(range(self.midata.n_channels))
|
|
2017
|
+
self.fill_channel_listView()
|
|
2018
|
+
self.redraw_all(second=self.current_sec)
|
|
2019
|
+
|
|
2020
|
+
def scaler_up(self):
|
|
2021
|
+
"""Zoom in on the selected channels."""
|
|
2022
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
2023
|
+
if not selected_channel:
|
|
2024
|
+
return
|
|
2025
|
+
self.y_lims = [
|
|
2026
|
+
lim * 0.9 if idx in selected_channel else lim
|
|
2027
|
+
for idx, lim in enumerate(self.y_lims)]
|
|
2028
|
+
self.plot_signals(clf=False, replot_axes=selected_channel)
|
|
2029
|
+
|
|
2030
|
+
def scaler_down(self):
|
|
2031
|
+
"""Zoom out on the selected channels."""
|
|
2032
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
2033
|
+
if not selected_channel:
|
|
2034
|
+
return
|
|
2035
|
+
self.y_lims = [
|
|
2036
|
+
lim * 1.1 if idx in selected_channel else lim
|
|
2037
|
+
for idx, lim in enumerate(self.y_lims)]
|
|
2038
|
+
self.plot_signals(clf=False, replot_axes=selected_channel)
|
|
2039
|
+
|
|
2040
|
+
def multiple_scaler(self):
|
|
2041
|
+
"""Scale the selected channels by a custom factor."""
|
|
2042
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
2043
|
+
scaler_num = self.multipleScalerEditor.value()
|
|
2044
|
+
if not selected_channel:
|
|
2045
|
+
return
|
|
2046
|
+
self.y_lims = [
|
|
2047
|
+
lim / scaler_num if idx in selected_channel else lim
|
|
2048
|
+
for idx, lim in enumerate(self.y_lims)]
|
|
2049
|
+
self.plot_signals(clf=False, replot_axes=selected_channel)
|
|
2050
|
+
|
|
2051
|
+
def shift_up(self):
|
|
2052
|
+
"""Shift the selected channels up."""
|
|
2053
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
2054
|
+
if not selected_channel:
|
|
2055
|
+
return
|
|
2056
|
+
self.y_shift = [
|
|
2057
|
+
shift - self.y_lims[idx] * 0.05 if idx in selected_channel else shift
|
|
2058
|
+
for idx, shift in enumerate(self.y_shift)]
|
|
2059
|
+
self.plot_signals(clf=False, replot_axes=selected_channel)
|
|
2060
|
+
|
|
2061
|
+
def shift_down(self):
|
|
2062
|
+
"""Shift the selected channels down."""
|
|
2063
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
2064
|
+
if not selected_channel:
|
|
2065
|
+
return
|
|
2066
|
+
self.y_shift = [
|
|
2067
|
+
shift + self.y_lims[idx] * 0.05 if idx in selected_channel else shift
|
|
2068
|
+
for idx, shift in enumerate(self.y_shift)]
|
|
2069
|
+
self.plot_signals(clf=False, replot_axes=selected_channel)
|
|
2070
|
+
|
|
2071
|
+
def filter_confirm(self):
|
|
2072
|
+
"""Apply a filter to the selected channel."""
|
|
2073
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
2074
|
+
if not selected_channel:
|
|
2075
|
+
return
|
|
2076
|
+
if len(selected_channel) > 1:
|
|
2077
|
+
QMessageBox.about(
|
|
2078
|
+
self, "Error",
|
|
2079
|
+
"Select one channel to be the default channel for spectrogram.")
|
|
2080
|
+
return
|
|
2081
|
+
|
|
2082
|
+
filter_type = self.FilterTypeCombo_dict[self.FilterTypeCombo.currentIndex()]
|
|
2083
|
+
low = self.FilterLowSpin.value()
|
|
2084
|
+
high = self.FilterHighSpin.value()
|
|
2085
|
+
if filter_type in ("bandpass", "bandstop") and low >= high:
|
|
2086
|
+
return
|
|
2087
|
+
self.midata.filter(
|
|
2088
|
+
chans=[self.midata.channels[selected_channel[0]]],
|
|
2089
|
+
btype=filter_type, low=low, high=high)
|
|
2090
|
+
self.y_lims.append(self.y_lims[selected_channel[0]])
|
|
2091
|
+
self.y_shift.append(self.y_shift[selected_channel[0]])
|
|
2092
|
+
|
|
2093
|
+
self.horizontal_line[self.midata.channels[-1]] = []
|
|
2094
|
+
self.show_idx.append(self.midata.n_channels - 1)
|
|
2095
|
+
self.fill_channel_listView()
|
|
2096
|
+
self.redraw_all(second=self.current_sec)
|
|
2097
|
+
|
|
2098
|
+
def FilterTypeCombo_change(self):
|
|
2099
|
+
"""Enable/disable the filter cut-off spins according to the filter type."""
|
|
2100
|
+
if self.FilterTypeCombo.currentIndex() in (0, 3):
|
|
2101
|
+
self.FilterHighSpin.setEnabled(True)
|
|
2102
|
+
self.FilterLowSpin.setEnabled(True)
|
|
2103
|
+
if self.FilterTypeCombo.currentIndex() == 1:
|
|
2104
|
+
self.FilterHighSpin.setDisabled(True)
|
|
2105
|
+
self.FilterLowSpin.setEnabled(True)
|
|
2106
|
+
if self.FilterTypeCombo.currentIndex() == 2:
|
|
2107
|
+
self.FilterHighSpin.setEnabled(True)
|
|
2108
|
+
self.FilterLowSpin.setDisabled(True)
|
|
2109
|
+
|
|
2110
|
+
def show_spec_window(self):
|
|
2111
|
+
"""Show the spectrum/spectrogram window for the selected segment."""
|
|
2112
|
+
from misleep.gui.spec_window import SpecWindow
|
|
2113
|
+
from misleep.preprocessing.spectral import band_power, spectrogram, spectrum
|
|
2114
|
+
|
|
2115
|
+
if self.SleepStateRadio.isChecked() and len(self.start_end) == 2 and \
|
|
2116
|
+
self.start_end[1] - self.start_end[0] >= 5:
|
|
2117
|
+
start_ = self.start_end[0]
|
|
2118
|
+
end_ = self.start_end[1]
|
|
2119
|
+
elif self.StartEndRadio.isChecked() and len(self.start_end_ms) == 2 and \
|
|
2120
|
+
self.start_end_ms[1] - self.start_end_ms[0] >= 5:
|
|
2121
|
+
start_ = self.start_end_ms[0]
|
|
2122
|
+
end_ = self.start_end_ms[1]
|
|
2123
|
+
else:
|
|
2124
|
+
QMessageBox.about(
|
|
2125
|
+
self, "Error",
|
|
2126
|
+
"Please select a start-end larger than 5 seconds.")
|
|
2127
|
+
return
|
|
2128
|
+
|
|
2129
|
+
selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
|
|
2130
|
+
if not selected_channel or len(selected_channel) > 1:
|
|
2131
|
+
QMessageBox.about(
|
|
2132
|
+
self, "Error",
|
|
2133
|
+
"Select one channel to show spectral analysis")
|
|
2134
|
+
return
|
|
2135
|
+
|
|
2136
|
+
channel = selected_channel[0]
|
|
2137
|
+
signal_data = self.midata.signals[channel][
|
|
2138
|
+
int(start_ * self.midata.sf[channel]): int(end_ * self.midata.sf[channel])]
|
|
2139
|
+
|
|
2140
|
+
win_sec = int(float(self.config["spec"]["win_length_sec"]))
|
|
2141
|
+
nfft = int(float(self.config["spec"]["nfft_sec"]) * self.midata.sf[channel])
|
|
2142
|
+
if win_sec > (end_ - start_):
|
|
2143
|
+
win_sec = end_ - start_
|
|
2144
|
+
if nfft < int(win_sec * self.midata.sf[channel]):
|
|
2145
|
+
nfft = int(win_sec * self.midata.sf[channel])
|
|
2146
|
+
|
|
2147
|
+
try:
|
|
2148
|
+
spec_window = self._ensure_dialog("spec_window", SpecWindow)
|
|
2149
|
+
freq_range = [float(x) for x in self.config["gui"]["freq_range"].strip("[]").split(",")]
|
|
2150
|
+
freq, psd = spectrum(
|
|
2151
|
+
signal=signal_data,
|
|
2152
|
+
sf=self.midata.sf[channel],
|
|
2153
|
+
band=freq_range,
|
|
2154
|
+
nfft=nfft,
|
|
2155
|
+
gaussian_sigma=float(self.config["spec"]["gaussian_sigma"]),
|
|
2156
|
+
win_sec=win_sec,
|
|
2157
|
+
relative=False)
|
|
2158
|
+
|
|
2159
|
+
f, t, Sxx = spectrogram(
|
|
2160
|
+
signal=signal_data,
|
|
2161
|
+
sf=self.midata.sf[channel],
|
|
2162
|
+
band=freq_range,
|
|
2163
|
+
step=1, win_sec=win_sec, norm=False, nfft=nfft)
|
|
2164
|
+
|
|
2165
|
+
bandPower = band_power(psd=psd, freq=freq,
|
|
2166
|
+
bands=[[0.5, 4, "delta"], [4, 9, "theta"]])
|
|
2167
|
+
ratio = bandPower["delta"] / bandPower["theta"]
|
|
2168
|
+
|
|
2169
|
+
spec_window.show_(
|
|
2170
|
+
spectrum=[psd, freq],
|
|
2171
|
+
spectrogram=[f, t, Sxx],
|
|
2172
|
+
percentile_=self.spectrogram_percentile,
|
|
2173
|
+
ratio=ratio, start_end=[start_, end_],
|
|
2174
|
+
freq_range=freq_range,
|
|
2175
|
+
data_path=self.data_path)
|
|
2176
|
+
|
|
2177
|
+
spec_window.activateWindow()
|
|
2178
|
+
state = spec_window.windowState()
|
|
2179
|
+
spec_window.setWindowState(
|
|
2180
|
+
(state & ~Qt.WindowState.WindowMinimized)
|
|
2181
|
+
| Qt.WindowState.WindowActive)
|
|
2182
|
+
spec_window.showNormal()
|
|
2183
|
+
except Exception:
|
|
2184
|
+
QMessageBox.about(
|
|
2185
|
+
self, "Error",
|
|
2186
|
+
"An error occurred while generating the spectral analysis.")
|
|
2187
|
+
return
|
|
2188
|
+
|
|
2189
|
+
def set_show_duration(self, type_="Combo"):
|
|
2190
|
+
"""Set the show duration from the combo box or the custom spin box."""
|
|
2191
|
+
if type_ == "Combo":
|
|
2192
|
+
selected_idx = self.ShowRangeCombo.currentIndex()
|
|
2193
|
+
if self.ShowRangeCombo_dict[selected_idx] + self.current_sec >= self.total_seconds:
|
|
2194
|
+
self.show_duration = 30
|
|
2195
|
+
self.current_sec = 0
|
|
2196
|
+
self.ShowRangeCombo.setCurrentIndex(0)
|
|
2197
|
+
else:
|
|
2198
|
+
self.show_duration = self.ShowRangeCombo_dict[selected_idx]
|
|
2199
|
+
if type_ == "Spin":
|
|
2200
|
+
show_duration = self.SecondNumSpin.value()
|
|
2201
|
+
if show_duration + self.current_sec >= self.total_seconds:
|
|
2202
|
+
self.show_duration = 30
|
|
2203
|
+
self.current_sec = 0
|
|
2204
|
+
self.SecondNumSpin.setValue(30)
|
|
2205
|
+
else:
|
|
2206
|
+
self.show_duration = show_duration
|
|
2207
|
+
|
|
2208
|
+
self.reset_sec_limit()
|
|
2209
|
+
self.redraw_all(second=self.current_sec)
|
|
2210
|
+
self.ScrollerBar.setPageStep(self.show_duration)
|
|
2211
|
+
|
|
2212
|
+
def CustomSecondCheck_clicked(self):
|
|
2213
|
+
if self.CustomSecondsCheck.isChecked():
|
|
2214
|
+
self.ShowRangeCombo.setDisabled(True)
|
|
2215
|
+
self.SecondNumSpin.setEnabled(True)
|
|
2216
|
+
self.set_show_duration(type_="Spin")
|
|
2217
|
+
else:
|
|
2218
|
+
self.ShowRangeCombo.setEnabled(True)
|
|
2219
|
+
self.SecondNumSpin.setDisabled(True)
|
|
2220
|
+
self.set_show_duration(type_="Combo")
|
|
2221
|
+
|
|
2222
|
+
def ShowRangeCombo_changed(self):
|
|
2223
|
+
self.set_show_duration(type_="Combo")
|
|
2224
|
+
|
|
2225
|
+
def SecondNumSpin_changed(self):
|
|
2226
|
+
self.set_show_duration(type_="Spin")
|
|
2227
|
+
|
|
2228
|
+
# ------------------------------------------------------------------
|
|
2229
|
+
# Labeling
|
|
2230
|
+
# ------------------------------------------------------------------
|
|
2231
|
+
def nrem_label(self):
|
|
2232
|
+
self.append_sleep_state(sleep_type=1)
|
|
2233
|
+
|
|
2234
|
+
def rem_label(self):
|
|
2235
|
+
self.append_sleep_state(sleep_type=2)
|
|
2236
|
+
|
|
2237
|
+
def wake_label(self):
|
|
2238
|
+
self.append_sleep_state(sleep_type=3)
|
|
2239
|
+
|
|
2240
|
+
def init_label(self):
|
|
2241
|
+
self.append_sleep_state(sleep_type=4)
|
|
2242
|
+
|
|
2243
|
+
def sleep_state_label(self, state_code=1):
|
|
2244
|
+
"""Label the selected area with a custom state code (from config)."""
|
|
2245
|
+
self.append_sleep_state(sleep_type=state_code)
|
|
2246
|
+
|
|
2247
|
+
def append_sleep_state(self, sleep_type=None):
|
|
2248
|
+
"""Assign a sleep state to the selected start-end area."""
|
|
2249
|
+
if len(self.start_end) != 2:
|
|
2250
|
+
QMessageBox.about(
|
|
2251
|
+
self, "Info", "Please select a start end area in Sleep state mode")
|
|
2252
|
+
return
|
|
2253
|
+
|
|
2254
|
+
self.mianno.sleep_state[self.start_end[0]: self.start_end[1]] = \
|
|
2255
|
+
[sleep_type] * (self.start_end[1] - self.start_end[0])
|
|
2256
|
+
self._hypo_revision += 1
|
|
2257
|
+
|
|
2258
|
+
self.is_saved = False
|
|
2259
|
+
self.AnnotationPathLabel.setText("*Annotation path:")
|
|
2260
|
+
self.replot_sleep_state_bg(state=sleep_type)
|
|
2261
|
+
self.plot_hypo()
|
|
2262
|
+
|
|
2263
|
+
def append_start_end(self):
|
|
2264
|
+
"""Append a start-end label to the annotation."""
|
|
2265
|
+
if not self.StartEndRadio.isChecked():
|
|
2266
|
+
QMessageBox.about(self, "Info", "Use Start-End mode")
|
|
2267
|
+
return
|
|
2268
|
+
if len(self.start_end_ms) != 2:
|
|
2269
|
+
QMessageBox.about(self, "Info", "Please select a start end area")
|
|
2270
|
+
return
|
|
2271
|
+
|
|
2272
|
+
self.label_dialog._type = 1
|
|
2273
|
+
self.label_dialog.show_contents()
|
|
2274
|
+
self.label_dialog.exec()
|
|
2275
|
+
if self.label_dialog.closed:
|
|
2276
|
+
return
|
|
2277
|
+
|
|
2278
|
+
label_name = self.label_dialog.label_name
|
|
2279
|
+
self.mianno.start_end.append(
|
|
2280
|
+
[self.start_end_ms[0], self.start_end_ms[1], label_name])
|
|
2281
|
+
self.plot_start_end_label_line()
|
|
2282
|
+
|
|
2283
|
+
self.is_saved = False
|
|
2284
|
+
self.AnnotationPathLabel.setText("*Annotation path:")
|
|
2285
|
+
|
|
2286
|
+
# ------------------------------------------------------------------
|
|
2287
|
+
# Tools / analysis
|
|
2288
|
+
# ------------------------------------------------------------------
|
|
2289
|
+
def transfer_result(self):
|
|
2290
|
+
"""Transfer the annotation into an Excel result file."""
|
|
2291
|
+
dialog = self._ensure_dialog(
|
|
2292
|
+
"transfer_result_dialog", TransferResultDialog)
|
|
2293
|
+
dialog.ACTimeEditor.setDateTime(self.ac_time)
|
|
2294
|
+
dialog.TransferStartTimeEdit.setDateTime(self.ac_time)
|
|
2295
|
+
dialog.exec()
|
|
2296
|
+
if dialog.closed:
|
|
2297
|
+
return
|
|
2298
|
+
try:
|
|
2299
|
+
with self._busy_operation():
|
|
2300
|
+
saved = dialog.transfer(
|
|
2301
|
+
config=self.config, mianno=self.mianno,
|
|
2302
|
+
ac_time=self.midata.time)
|
|
2303
|
+
except Exception as exc:
|
|
2304
|
+
logger.exception("Transfer result failed")
|
|
2305
|
+
QMessageBox.about(self, "Error", f"Transfer result failed: {exc}")
|
|
2306
|
+
return
|
|
2307
|
+
if saved:
|
|
2308
|
+
QMessageBox.about(self, "Info", "Transferred result saved")
|
|
2309
|
+
|
|
2310
|
+
def state_spectral(self):
|
|
2311
|
+
"""Run the per-state spectral analysis."""
|
|
2312
|
+
dialog = self._ensure_dialog(
|
|
2313
|
+
"state_spectral_dialog", lambda: StateSpectralDialog(config=self.config))
|
|
2314
|
+
dialog.StartTimeEditor.setDateTime(self.ac_time)
|
|
2315
|
+
dialog.EndTimeEditor.setDateTime(
|
|
2316
|
+
self.ac_time + datetime.timedelta(seconds=self.total_seconds))
|
|
2317
|
+
dialog.dialog_show(channels=self.midata.channels)
|
|
2318
|
+
dialog.exec()
|
|
2319
|
+
if dialog.closed:
|
|
2320
|
+
return
|
|
2321
|
+
try:
|
|
2322
|
+
with self._busy_operation():
|
|
2323
|
+
saved = dialog.spectral_analysis(
|
|
2324
|
+
midata=self.midata, mianno=self.mianno,
|
|
2325
|
+
config=self.config)
|
|
2326
|
+
except Exception as exc:
|
|
2327
|
+
logger.exception("State spectral export failed")
|
|
2328
|
+
QMessageBox.about(self, "Error", f"Spectral export failed: {exc}")
|
|
2329
|
+
return
|
|
2330
|
+
if saved:
|
|
2331
|
+
QMessageBox.about(self, "Info", "Spectral analysis finished")
|
|
2332
|
+
|
|
2333
|
+
def add_horizontal_line(self):
|
|
2334
|
+
"""Add a horizontal reference line."""
|
|
2335
|
+
try:
|
|
2336
|
+
dialog = self._ensure_dialog(
|
|
2337
|
+
"horizontal_line_dialog", HorizontalLineDialog)
|
|
2338
|
+
dialog.horizontal_line = copy.deepcopy(self.horizontal_line)
|
|
2339
|
+
dialog.show_chs()
|
|
2340
|
+
dialog.midata = self.midata
|
|
2341
|
+
dialog.exec()
|
|
2342
|
+
if dialog.closed:
|
|
2343
|
+
return
|
|
2344
|
+
self.horizontal_line = dialog.horizontal_line
|
|
2345
|
+
self.plot_horizontal_line()
|
|
2346
|
+
except Exception as e:
|
|
2347
|
+
logger.error(f"Add horizontal line ERROR: {e}")
|
|
2348
|
+
QMessageBox.about(self, "Error", str(e))
|
|
2349
|
+
return
|
|
2350
|
+
|
|
2351
|
+
def swa_detection(self):
|
|
2352
|
+
"""Slow-wave activity detection."""
|
|
2353
|
+
try:
|
|
2354
|
+
dialog = self._ensure_dialog(
|
|
2355
|
+
"swa_detection_dialog", SWADetectionDialog)
|
|
2356
|
+
dialog.show_chs(self.midata.channels)
|
|
2357
|
+
dialog.exec()
|
|
2358
|
+
if dialog.closed:
|
|
2359
|
+
return
|
|
2360
|
+
swa_lst = dialog.swa_detection(
|
|
2361
|
+
self.midata, self.mianno, self.config)
|
|
2362
|
+
|
|
2363
|
+
self.mianno._start_end += [[each[0], each[4], "SWA"] for each in swa_lst]
|
|
2364
|
+
self.plot_start_end_label_line()
|
|
2365
|
+
self.is_saved = False
|
|
2366
|
+
self.AnnotationPathLabel.setText("*Annotation path:")
|
|
2367
|
+
except Exception as e:
|
|
2368
|
+
logger.error(f"SWA_detection ERROR: {e}")
|
|
2369
|
+
QMessageBox.about(self, "Error", f"SWA detection ERROR. {e}")
|
|
2370
|
+
return
|
|
2371
|
+
|
|
2372
|
+
def spindle_detection(self):
|
|
2373
|
+
"""Sleep spindle detection."""
|
|
2374
|
+
try:
|
|
2375
|
+
dialog = self._ensure_dialog(
|
|
2376
|
+
"spindel_detection_dialog", SpindleDetectionDialog)
|
|
2377
|
+
dialog.show_chs(self.midata.channels)
|
|
2378
|
+
dialog.exec()
|
|
2379
|
+
if dialog.closed:
|
|
2380
|
+
return
|
|
2381
|
+
spindle_lst = dialog.spindle_detection(
|
|
2382
|
+
self.midata, self.mianno, self.config)
|
|
2383
|
+
|
|
2384
|
+
self.mianno._start_end += [[each[0], each[1], "Spindle"] for each in spindle_lst]
|
|
2385
|
+
self.plot_start_end_label_line()
|
|
2386
|
+
self.is_saved = False
|
|
2387
|
+
self.AnnotationPathLabel.setText("*Annotation path:")
|
|
2388
|
+
except Exception as e:
|
|
2389
|
+
logger.error(f"Spindel_detection ERROR: {e}")
|
|
2390
|
+
QMessageBox.about(self, "Error", "Spindle detection ERROR")
|
|
2391
|
+
return
|
|
2392
|
+
|
|
2393
|
+
def auto_stage_LightGBM(self):
|
|
2394
|
+
"""Auto stage with the LightGBM model."""
|
|
2395
|
+
try:
|
|
2396
|
+
dialog = self._ensure_dialog(
|
|
2397
|
+
"auto_stage_lightGBM_dialog", AutoStageLightGBMDialog)
|
|
2398
|
+
dialog.show_chs(self.midata.channels)
|
|
2399
|
+
dialog.exec()
|
|
2400
|
+
if dialog.closed:
|
|
2401
|
+
return
|
|
2402
|
+
auto_stage_lst, save_anno = dialog.auto_stage(
|
|
2403
|
+
self.midata, self.mianno)
|
|
2404
|
+
|
|
2405
|
+
limit = min(len(self.mianno._sleep_state), len(auto_stage_lst))
|
|
2406
|
+
self.mianno._sleep_state[:limit] = auto_stage_lst[:limit]
|
|
2407
|
+
self._hypo_revision += 1
|
|
2408
|
+
|
|
2409
|
+
if save_anno:
|
|
2410
|
+
self.save_anno()
|
|
2411
|
+
self.is_saved = False
|
|
2412
|
+
self.AnnotationPathLabel.setText("*Annotation path:")
|
|
2413
|
+
self.plot_signals()
|
|
2414
|
+
self.plot_hypo()
|
|
2415
|
+
except ImportError as e:
|
|
2416
|
+
QMessageBox.about(self, "Error", str(e))
|
|
2417
|
+
return
|
|
2418
|
+
except Exception as e:
|
|
2419
|
+
logger.error(f"Auto stage ERROR: {e}")
|
|
2420
|
+
QMessageBox.about(self, "Error", "Auto stage ERROR")
|
|
2421
|
+
return
|
|
2422
|
+
|
|
2423
|
+
def auto_stage_CausalTransformer(self):
|
|
2424
|
+
"""Auto stage with the causal-transformer model."""
|
|
2425
|
+
try:
|
|
2426
|
+
dialog = self._ensure_dialog(
|
|
2427
|
+
"auto_stage_causalTransformer_dialog",
|
|
2428
|
+
AutoStageCausalTransformerDialog)
|
|
2429
|
+
dialog.show_chs(self.midata.channels)
|
|
2430
|
+
dialog.exec()
|
|
2431
|
+
if dialog.closed:
|
|
2432
|
+
return
|
|
2433
|
+
auto_stage_lst, save_anno = dialog.auto_stage(self.midata, self.mianno)
|
|
2434
|
+
|
|
2435
|
+
limit = min(len(self.mianno._sleep_state), len(auto_stage_lst))
|
|
2436
|
+
self.mianno._sleep_state[:limit] = auto_stage_lst[:limit]
|
|
2437
|
+
self._hypo_revision += 1
|
|
2438
|
+
|
|
2439
|
+
if save_anno:
|
|
2440
|
+
self.save_anno()
|
|
2441
|
+
self.is_saved = False
|
|
2442
|
+
self.AnnotationPathLabel.setText("*Annotation path:")
|
|
2443
|
+
self.plot_signals()
|
|
2444
|
+
self.plot_hypo()
|
|
2445
|
+
except ImportError as e:
|
|
2446
|
+
QMessageBox.about(self, "Error", str(e))
|
|
2447
|
+
return
|
|
2448
|
+
except Exception as e:
|
|
2449
|
+
logger.error(f"Auto stage ERROR: {e}")
|
|
2450
|
+
QMessageBox.about(self, "Error", "Auto stage ERROR")
|
|
2451
|
+
return
|
|
2452
|
+
|
|
2453
|
+
# ------------------------------------------------------------------
|
|
2454
|
+
# Saving
|
|
2455
|
+
# ------------------------------------------------------------------
|
|
2456
|
+
def save_anno(self, just_save=False):
|
|
2457
|
+
"""Save the annotation to file.
|
|
2458
|
+
|
|
2459
|
+
Parameters
|
|
2460
|
+
----------
|
|
2461
|
+
just_save : bool
|
|
2462
|
+
Only save without touching the current annotation path.
|
|
2463
|
+
"""
|
|
2464
|
+
if self.anno_path == "":
|
|
2465
|
+
anno_path, _ = QFileDialog.getOpenFileName(
|
|
2466
|
+
self, "Select annotation file",
|
|
2467
|
+
f"{self.config['gui']['openpath']}",
|
|
2468
|
+
"txt Files (*.txt *.TXT)")
|
|
2469
|
+
if anno_path == "":
|
|
2470
|
+
return
|
|
2471
|
+
if not just_save:
|
|
2472
|
+
self.anno_path = anno_path
|
|
2473
|
+
self.AnnoPathEdit.setText(self.anno_path)
|
|
2474
|
+
|
|
2475
|
+
save_thread = SaveThread(file=[self.mianno, self.midata],
|
|
2476
|
+
file_path=self.anno_path)
|
|
2477
|
+
saved = save_thread.save_anno()
|
|
2478
|
+
if saved and not just_save:
|
|
2479
|
+
self.is_saved = True
|
|
2480
|
+
self.AnnotationPathLabel.setText("Annotation path:")
|
|
2481
|
+
save_thread.quit()
|
|
2482
|
+
|
|
2483
|
+
def save_data(self):
|
|
2484
|
+
"""Export (cropped, channel-selected) data to ``.mat``/``.edf``."""
|
|
2485
|
+
dialog = self._ensure_dialog("save_data_dialog", SaveDataDialog)
|
|
2486
|
+
dialog.fill_midata_params(midata=self.midata)
|
|
2487
|
+
dialog.exec()
|
|
2488
|
+
if dialog.closed:
|
|
2489
|
+
return
|
|
2490
|
+
|
|
2491
|
+
selected_channels = [each.row() for each in
|
|
2492
|
+
dialog.ChannelListView.selectedIndexes()]
|
|
2493
|
+
if selected_channels == []:
|
|
2494
|
+
selected_channels = list(range(self.midata.n_channels))
|
|
2495
|
+
midata_to_save = self.midata.pick_chs(
|
|
2496
|
+
[self.midata.channels[each] for each in selected_channels])
|
|
2497
|
+
|
|
2498
|
+
start_sec = 0
|
|
2499
|
+
end_sec = self.midata.duration
|
|
2500
|
+
|
|
2501
|
+
if dialog.CropDataStartCheckBox.isChecked():
|
|
2502
|
+
start_time = dialog.CropStartTimeEditor.dateTime().toPython()
|
|
2503
|
+
midata_to_save._time = start_time.strftime("%Y%m%d-%H:%M:%S")
|
|
2504
|
+
start_sec = int(datetime.timedelta.total_seconds(start_time - self.ac_time))
|
|
2505
|
+
if start_sec < 0:
|
|
2506
|
+
start_sec = 0
|
|
2507
|
+
|
|
2508
|
+
if dialog.CropDataEndCheckBox.isChecked():
|
|
2509
|
+
end_time = dialog.CropEndTimeEditor.dateTime().toPython()
|
|
2510
|
+
end_sec = int(datetime.timedelta.total_seconds(end_time - self.ac_time))
|
|
2511
|
+
if end_sec > self.midata.duration:
|
|
2512
|
+
end_sec = self.midata.duration
|
|
2513
|
+
|
|
2514
|
+
if end_sec <= start_sec:
|
|
2515
|
+
start_sec = 0
|
|
2516
|
+
end_sec = self.midata.duration
|
|
2517
|
+
|
|
2518
|
+
midata_to_save = midata_to_save.crop([start_sec, end_sec])
|
|
2519
|
+
|
|
2520
|
+
extensions = available_writers()
|
|
2521
|
+
filters = ";;".join(
|
|
2522
|
+
f"{extension[1:].upper()} files (*{extension})"
|
|
2523
|
+
for extension in extensions)
|
|
2524
|
+
data_path, _ = QFileDialog.getSaveFileName(
|
|
2525
|
+
self, "Select a file to save data",
|
|
2526
|
+
f"{self.config['gui']['openpath']}_misleep_saved",
|
|
2527
|
+
filters)
|
|
2528
|
+
if data_path == "":
|
|
2529
|
+
return
|
|
2530
|
+
|
|
2531
|
+
save_thread = SaveThread(file=midata_to_save, file_path=data_path)
|
|
2532
|
+
try:
|
|
2533
|
+
with self._busy_operation():
|
|
2534
|
+
saved = save_thread.save_data()
|
|
2535
|
+
except Exception as exc:
|
|
2536
|
+
logger.exception("Data export failed")
|
|
2537
|
+
QMessageBox.about(self, "Error", f"Data save failed: {exc}")
|
|
2538
|
+
return
|
|
2539
|
+
if saved:
|
|
2540
|
+
QMessageBox.about(self, "Info", f"Data Saved to {data_path}")
|
|
2541
|
+
else:
|
|
2542
|
+
QMessageBox.about(self, "Error", "Data save ERROR")
|
|
2543
|
+
save_thread.quit()
|
|
2544
|
+
|
|
2545
|
+
# ------------------------------------------------------------------
|
|
2546
|
+
# Configuration
|
|
2547
|
+
# ------------------------------------------------------------------
|
|
2548
|
+
def open_settings_dialog(self):
|
|
2549
|
+
"""Open the in-application settings dialog."""
|
|
2550
|
+
dialog = SettingsDialog(self)
|
|
2551
|
+
dialog.exec()
|
|
2552
|
+
|
|
2553
|
+
def apply_settings(self):
|
|
2554
|
+
"""Apply the current configuration to the running application.
|
|
2555
|
+
|
|
2556
|
+
Called after the settings dialog saves changes, so that state
|
|
2557
|
+
names/colors, labels and spectral defaults take effect
|
|
2558
|
+
immediately without restarting MiSleep.
|
|
2559
|
+
"""
|
|
2560
|
+
gui = self.config["gui"]
|
|
2561
|
+
|
|
2562
|
+
# Apply a theme change (chosen in the settings dialog) immediately
|
|
2563
|
+
new_theme = gui.get("theme", fallback="light")
|
|
2564
|
+
new_tone = gui.get("color_tone", fallback="black")
|
|
2565
|
+
if new_theme not in THEMES:
|
|
2566
|
+
new_theme = "light"
|
|
2567
|
+
if new_tone not in COLOR_TONES:
|
|
2568
|
+
new_tone = "black"
|
|
2569
|
+
if new_theme != self._theme_name or new_tone != self._tone_name:
|
|
2570
|
+
self._theme_name = new_theme
|
|
2571
|
+
self._tone_name = new_tone
|
|
2572
|
+
app = QApplication.instance()
|
|
2573
|
+
if app is not None:
|
|
2574
|
+
apply_theme(app, new_theme, new_tone)
|
|
2575
|
+
retheme_figures(new_theme, new_tone)
|
|
2576
|
+
self._set_plot_colors()
|
|
2577
|
+
self._update_theme_action()
|
|
2578
|
+
|
|
2579
|
+
# Reload the parsed dictionaries
|
|
2580
|
+
self.state_map_dict = {int(key): value for key, value
|
|
2581
|
+
in json.loads(gui["statemap"]).items()}
|
|
2582
|
+
self.state_color_dict = {int(key): value for key, value
|
|
2583
|
+
in json.loads(gui["statecolor"]).items()}
|
|
2584
|
+
self.start_end_color_dict = dict(
|
|
2585
|
+
json.loads(gui["startendcolor"].replace("'", '"')).items())
|
|
2586
|
+
|
|
2587
|
+
# Rebuild the state-label buttons (names/colors/extra states)
|
|
2588
|
+
self._sync_state_buttons()
|
|
2589
|
+
|
|
2590
|
+
# Refresh the label picker lists
|
|
2591
|
+
self.label_dialog.marker_label = [
|
|
2592
|
+
each[1:-1] for each in gui["marker"][1:-1].split(", ")]
|
|
2593
|
+
self.label_dialog.start_end_label = [
|
|
2594
|
+
each[1:-1] for each in gui["startend"][1:-1].split(", ")]
|
|
2595
|
+
|
|
2596
|
+
# Refresh spectral defaults used by the state-spectral dialog
|
|
2597
|
+
if self.state_spectral_dialog is not None:
|
|
2598
|
+
self.state_spectral_dialog.GaussianSpinBox.setValue(
|
|
2599
|
+
float(self.config["spec"]["gaussian_sigma"]))
|
|
2600
|
+
self.state_spectral_dialog.WinLengthSpinBox.setValue(
|
|
2601
|
+
float(self.config["spec"]["win_length_sec"]))
|
|
2602
|
+
self.state_spectral_dialog.nfftSpinBox.setValue(
|
|
2603
|
+
int(float(self.config["spec"]["nfft_sec"])))
|
|
2604
|
+
|
|
2605
|
+
# Update the annotation state map if one is loaded
|
|
2606
|
+
if isinstance(self.mianno, MiAnnotation):
|
|
2607
|
+
self.mianno._state_map = self.state_map_dict
|
|
2608
|
+
|
|
2609
|
+
# State names/colors are part of the hypnogram itself. Force a base
|
|
2610
|
+
# rebuild now instead of waiting for the next annotation edit.
|
|
2611
|
+
self._hypo_key = None
|
|
2612
|
+
self._hypo_revision += 1
|
|
2613
|
+
self._hypo_steps = []
|
|
2614
|
+
self._hypo_transient = []
|
|
2615
|
+
# Frequency range changes also invalidate the cached whole-file STFT.
|
|
2616
|
+
self._spec_full_cache = {}
|
|
2617
|
+
|
|
2618
|
+
# Redraw with the new colors / frequency range (skip before data loads)
|
|
2619
|
+
if self.midata is not None and self.mianno is not None:
|
|
2620
|
+
self.redraw_all(second=self.current_sec)
|
|
2621
|
+
else:
|
|
2622
|
+
logger.info("Settings applied; they will take effect after loading data.")
|
|
2623
|
+
|
|
2624
|
+
def pupup_config(self):
|
|
2625
|
+
"""Open the user configuration file with the system editor."""
|
|
2626
|
+
import platform
|
|
2627
|
+
import subprocess
|
|
2628
|
+
|
|
2629
|
+
path = str(user_config_path())
|
|
2630
|
+
try:
|
|
2631
|
+
system = platform.system()
|
|
2632
|
+
if system == "Windows":
|
|
2633
|
+
os.startfile(path)
|
|
2634
|
+
elif system == "Darwin":
|
|
2635
|
+
subprocess.run(["open", path])
|
|
2636
|
+
elif system == "Linux":
|
|
2637
|
+
subprocess.run(["xdg-open", path])
|
|
2638
|
+
except Exception as e:
|
|
2639
|
+
logger.error(f"Open config.ini ERROR: {e}")
|
|
2640
|
+
QMessageBox.about(self, "Error", f"Open config.ini ERROR: {e}")
|
|
2641
|
+
|
|
2642
|
+
def auto_save(self):
|
|
2643
|
+
"""Auto-save the annotation every 5 minutes when modified."""
|
|
2644
|
+
if not self.is_saved:
|
|
2645
|
+
self.save_anno()
|
|
2646
|
+
self.save_timer.start(5 * 60 * 1000)
|
|
2647
|
+
|
|
2648
|
+
def save_config(self, config_dict):
|
|
2649
|
+
"""Persist GUI configuration changes to the user config file."""
|
|
2650
|
+
for key, value in config_dict.items():
|
|
2651
|
+
self.config.set("gui", key, value)
|
|
2652
|
+
save_config(self.config)
|
|
2653
|
+
|
|
2654
|
+
# ------------------------------------------------------------------
|
|
2655
|
+
# Event handlers
|
|
2656
|
+
# ------------------------------------------------------------------
|
|
2657
|
+
def keyPressEvent(self, event):
|
|
2658
|
+
"""Number keys label the selected area with that state.
|
|
2659
|
+
|
|
2660
|
+
Keys 1-9 map to states 1-9; key 0 maps to state 10 (when defined),
|
|
2661
|
+
supporting up to ten states.
|
|
2662
|
+
"""
|
|
2663
|
+
if Qt.Key.Key_0 <= event.key() <= Qt.Key.Key_9:
|
|
2664
|
+
num = int(event.text())
|
|
2665
|
+
state = 10 if num == 0 else num
|
|
2666
|
+
if state in self.state_map_dict.keys():
|
|
2667
|
+
self.sleep_state_label(state_code=state)
|
|
2668
|
+
else:
|
|
2669
|
+
QWidget.keyPressEvent(self, event)
|
|
2670
|
+
|
|
2671
|
+
def closeEvent(self, event):
|
|
2672
|
+
"""Ask for confirmation when there are unsaved labels."""
|
|
2673
|
+
if not self.is_saved:
|
|
2674
|
+
box = QMessageBox.question(
|
|
2675
|
+
self, "Warning",
|
|
2676
|
+
"Your labels haven't been saved, discard?\n"
|
|
2677
|
+
"Yes: Save and quit\nNo: Discard\nCancel: Nothing",
|
|
2678
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
2679
|
+
| QMessageBox.StandardButton.Cancel,
|
|
2680
|
+
QMessageBox.StandardButton.Yes)
|
|
2681
|
+
|
|
2682
|
+
if box == QMessageBox.StandardButton.Yes:
|
|
2683
|
+
self.save_anno()
|
|
2684
|
+
event.accept()
|
|
2685
|
+
elif box == QMessageBox.StandardButton.No:
|
|
2686
|
+
event.accept()
|
|
2687
|
+
else:
|
|
2688
|
+
event.ignore()
|
|
2689
|
+
|
|
2690
|
+
if event.isAccepted():
|
|
2691
|
+
self.save_timer.stop()
|
|
2692
|
+
if self.spec_window is not None:
|
|
2693
|
+
self.spec_window.close()
|
|
2694
|
+
plt.close(self.signal_figure)
|
|
2695
|
+
plt.close(self.hypo_figure)
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
# Backward-compatible alias
|
|
2699
|
+
main_window = MainWindow
|