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
misleep/gui/app.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Application entry point for the MiSleep GUI.
|
|
3
|
+
|
|
4
|
+
Sets up the Qt/PySide6 environment (including the ``QT_API`` variable
|
|
5
|
+
needed by matplotlib) and starts the main window.
|
|
6
|
+
|
|
7
|
+
Files can be opened directly from the command line::
|
|
8
|
+
|
|
9
|
+
misleep # empty session
|
|
10
|
+
misleep data.mat # load data on startup
|
|
11
|
+
misleep data.mat anno.txt # load data + annotation on startup
|
|
12
|
+
misleep --data data.edf --anno anno.txt
|
|
13
|
+
python -m misleep data.mat anno.txt
|
|
14
|
+
|
|
15
|
+
On Windows, after running ``tools/install_file_associations.py``,
|
|
16
|
+
double-clicking a registered ``.mat`` / ``.edf`` file opens it in MiSleep
|
|
17
|
+
automatically.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# Make matplotlib use the PySide6 Qt bindings before anything imports it.
|
|
25
|
+
os.environ.setdefault("QT_API", "pyside6")
|
|
26
|
+
|
|
27
|
+
from misleep.gui import resources # noqa: F401 (register Qt resources)
|
|
28
|
+
from misleep.gui.qt_utils import app_icon
|
|
29
|
+
from misleep.logger import logger
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _setup_high_dpi():
|
|
33
|
+
"""Configure Qt high-DPI behaviour before the QApplication is created.
|
|
34
|
+
|
|
35
|
+
Qt6 enables high-DPI scaling by default; this additionally:
|
|
36
|
+
|
|
37
|
+
* uses *pass-through* scale-factor rounding so fractional display
|
|
38
|
+
scales (e.g. 125 % / 150 % on 2K screens) are applied exactly
|
|
39
|
+
instead of being rounded to 0.5 steps,
|
|
40
|
+
* requests per-monitor DPI awareness on Windows (best effort) so the
|
|
41
|
+
window is crisp when moved between monitors with different scales.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
from PySide6.QtCore import Qt
|
|
45
|
+
from PySide6.QtGui import QGuiApplication
|
|
46
|
+
|
|
47
|
+
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
|
48
|
+
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
|
|
49
|
+
except Exception: # pragma: no cover
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
if sys.platform == "win32":
|
|
53
|
+
try:
|
|
54
|
+
import ctypes
|
|
55
|
+
|
|
56
|
+
ctypes.windll.shcore.SetProcessDpiAwareness(2) # per-monitor v2
|
|
57
|
+
except Exception:
|
|
58
|
+
try:
|
|
59
|
+
import ctypes
|
|
60
|
+
|
|
61
|
+
ctypes.windll.user32.SetProcessDPIAware()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_args(argv=None):
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="misleep",
|
|
69
|
+
description="MiSleep: mice sleep EEG/EMG visualization, scoring and analysis.",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"files", nargs="*", metavar="FILE",
|
|
73
|
+
help="Optional signal file (.mat/.edf) and, optionally, annotation file "
|
|
74
|
+
"(.txt). e.g. `misleep data.mat anno.txt`")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--data", dest="data_path", default=None, metavar="PATH",
|
|
77
|
+
help="Signal file (.mat/.edf) to open on startup.")
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--anno", dest="anno_path", default=None, metavar="PATH",
|
|
80
|
+
help="Annotation file (.txt) to open on startup.")
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--version", action="version",
|
|
83
|
+
version=f"%(prog)s {_version()}",
|
|
84
|
+
)
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
|
|
87
|
+
# positional files: first = data, second = annotation.
|
|
88
|
+
# When --data is already given, a single positional file is the annotation.
|
|
89
|
+
data_path = args.data_path
|
|
90
|
+
anno_path = args.anno_path
|
|
91
|
+
if args.files:
|
|
92
|
+
if data_path is None:
|
|
93
|
+
data_path = args.files[0]
|
|
94
|
+
if len(args.files) > 1:
|
|
95
|
+
anno_path = anno_path or args.files[1]
|
|
96
|
+
else:
|
|
97
|
+
anno_path = anno_path or args.files[0]
|
|
98
|
+
return data_path, anno_path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _version():
|
|
102
|
+
from misleep import __version__
|
|
103
|
+
|
|
104
|
+
return __version__
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _prewarm():
|
|
108
|
+
"""Pay the one-time warm-up costs (font cache, heavy scipy imports)
|
|
109
|
+
at application startup instead of inside the first file load.
|
|
110
|
+
|
|
111
|
+
Without this, the very first plot after loading a file silently pays
|
|
112
|
+
~1-2 s of matplotlib font-cache building and scipy imports, which the
|
|
113
|
+
user perceives as a slow "initialize/draw" phase.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
import scipy.integrate # noqa: F401
|
|
117
|
+
import scipy.ndimage # noqa: F401
|
|
118
|
+
import scipy.signal # noqa: F401
|
|
119
|
+
|
|
120
|
+
from matplotlib import font_manager
|
|
121
|
+
font_manager.findfont("DejaVu Sans") # build / load the font cache
|
|
122
|
+
|
|
123
|
+
import matplotlib.pyplot as plt
|
|
124
|
+
plt.get_cmap("jet")
|
|
125
|
+
|
|
126
|
+
from misleep.preprocessing import spectral # noqa: F401
|
|
127
|
+
from misleep.viz import spectral as _viz_spectral # noqa: F401
|
|
128
|
+
except Exception: # pragma: no cover
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def show(data_path=None, anno_path=None):
|
|
133
|
+
"""Create and show the MiSleep main window (blocking).
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
data_path : str, optional
|
|
138
|
+
Signal file (``.mat`` / ``.edf``) to open on startup.
|
|
139
|
+
anno_path : str, optional
|
|
140
|
+
Annotation file (``.txt``) to open on startup.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
from PySide6.QtWidgets import QApplication
|
|
144
|
+
except ImportError as e: # pragma: no cover
|
|
145
|
+
raise ImportError(
|
|
146
|
+
"The MiSleep GUI requires PySide6. Install it with: "
|
|
147
|
+
"pip install 'misleep[gui]' (or: pip install PySide6)"
|
|
148
|
+
) from e
|
|
149
|
+
|
|
150
|
+
_setup_high_dpi()
|
|
151
|
+
|
|
152
|
+
# Imported after the Qt bindings are resolved by matplotlib.
|
|
153
|
+
from misleep.gui.main_window import MainWindow
|
|
154
|
+
|
|
155
|
+
app = QApplication.instance() or QApplication(sys.argv[:1])
|
|
156
|
+
app.setApplicationName("MiSleep")
|
|
157
|
+
app.setApplicationDisplayName("MiSleep")
|
|
158
|
+
app.setWindowIcon(app_icon())
|
|
159
|
+
|
|
160
|
+
# Warm-up one-time costs so the first file load renders immediately
|
|
161
|
+
_prewarm()
|
|
162
|
+
|
|
163
|
+
main_win = MainWindow()
|
|
164
|
+
|
|
165
|
+
if data_path:
|
|
166
|
+
logger.info("Opening data file: %s", data_path)
|
|
167
|
+
main_win.open_data(data_path)
|
|
168
|
+
if anno_path:
|
|
169
|
+
logger.info("Opening annotation file: %s", anno_path)
|
|
170
|
+
main_win.open_annotation(anno_path)
|
|
171
|
+
|
|
172
|
+
main_win.showMaximized() # start maximized: no horizontal scrolling
|
|
173
|
+
sys.exit(app.exec())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv=None):
|
|
177
|
+
"""Console-script entry point (``misleep``)."""
|
|
178
|
+
data_path, anno_path = _parse_args(argv)
|
|
179
|
+
show(data_path=data_path, anno_path=anno_path)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""In-application settings dialog.
|
|
3
|
+
|
|
4
|
+
Lets the user edit the MiSleep configuration from inside the GUI
|
|
5
|
+
(state names & colors, marker color, annotation labels, spectral defaults,
|
|
6
|
+
...). Changes are saved to the per-user configuration
|
|
7
|
+
file and **applied immediately** to the running application -- no
|
|
8
|
+
restart needed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import Qt
|
|
14
|
+
from PySide6.QtGui import QColor
|
|
15
|
+
from PySide6.QtWidgets import (
|
|
16
|
+
QColorDialog,
|
|
17
|
+
QComboBox,
|
|
18
|
+
QDialog,
|
|
19
|
+
QDoubleSpinBox,
|
|
20
|
+
QFileDialog,
|
|
21
|
+
QFormLayout,
|
|
22
|
+
QGroupBox,
|
|
23
|
+
QHBoxLayout,
|
|
24
|
+
QLabel,
|
|
25
|
+
QLineEdit,
|
|
26
|
+
QListWidget,
|
|
27
|
+
QListWidgetItem,
|
|
28
|
+
QMessageBox,
|
|
29
|
+
QPushButton,
|
|
30
|
+
QScrollArea,
|
|
31
|
+
QTabWidget,
|
|
32
|
+
QVBoxLayout,
|
|
33
|
+
QWidget,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from misleep.config import save_config
|
|
37
|
+
from misleep.gui.qt_utils import app_icon
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _contrast_text(color: QColor) -> str:
|
|
41
|
+
"""Return black or white text depending on the background luminance."""
|
|
42
|
+
lum = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
|
|
43
|
+
return "#000000" if lum > 150 else "#ffffff"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ColorButton(QPushButton):
|
|
47
|
+
"""A rounded color swatch that opens a color picker when clicked."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, color="#ffffff", parent=None):
|
|
50
|
+
super().__init__(parent)
|
|
51
|
+
self._color = QColor(color)
|
|
52
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
53
|
+
self.setFixedSize(64, 26)
|
|
54
|
+
self.setToolTip("Click to choose a color")
|
|
55
|
+
self.set_color(self._color)
|
|
56
|
+
self.clicked.connect(self._pick)
|
|
57
|
+
|
|
58
|
+
def _pick(self):
|
|
59
|
+
c = QColorDialog.getColor(self._color, self, "Select color")
|
|
60
|
+
if c.isValid():
|
|
61
|
+
self.set_color(c)
|
|
62
|
+
|
|
63
|
+
def set_color(self, color):
|
|
64
|
+
self._color = QColor(color)
|
|
65
|
+
hex_name = self._color.name().upper()
|
|
66
|
+
self.setText(hex_name)
|
|
67
|
+
self.setFont(self.font())
|
|
68
|
+
self.setStyleSheet(
|
|
69
|
+
f"""
|
|
70
|
+
QPushButton {{
|
|
71
|
+
background-color: {self._color.name()};
|
|
72
|
+
color: {_contrast_text(self._color)};
|
|
73
|
+
border: 2px solid rgba(0, 0, 0, 60%);
|
|
74
|
+
border-radius: 4px;
|
|
75
|
+
font-size: 8pt;
|
|
76
|
+
font-weight: bold;
|
|
77
|
+
padding: 0px;
|
|
78
|
+
}}
|
|
79
|
+
QPushButton:hover {{
|
|
80
|
+
border: 2px solid #000000;
|
|
81
|
+
}}
|
|
82
|
+
QPushButton:pressed {{
|
|
83
|
+
background-color: {self._color.darker(120).name()};
|
|
84
|
+
}}
|
|
85
|
+
"""
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def color(self) -> QColor:
|
|
89
|
+
return self._color
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _ListEditor(QWidget):
|
|
93
|
+
"""A small editable string list (add / delete / edit items)."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, values, parent=None):
|
|
96
|
+
super().__init__(parent)
|
|
97
|
+
layout = QHBoxLayout(self)
|
|
98
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
99
|
+
|
|
100
|
+
self.list = QListWidget()
|
|
101
|
+
self.list.addItems(values)
|
|
102
|
+
layout.addWidget(self.list, 1)
|
|
103
|
+
|
|
104
|
+
btns = QVBoxLayout()
|
|
105
|
+
add_bt = QPushButton("Add")
|
|
106
|
+
del_bt = QPushButton("Delete")
|
|
107
|
+
add_bt.clicked.connect(self._add)
|
|
108
|
+
del_bt.clicked.connect(self._delete)
|
|
109
|
+
btns.addWidget(add_bt)
|
|
110
|
+
btns.addWidget(del_bt)
|
|
111
|
+
btns.addStretch(1)
|
|
112
|
+
layout.addLayout(btns)
|
|
113
|
+
|
|
114
|
+
def _add(self):
|
|
115
|
+
item = QListWidgetItem("new label")
|
|
116
|
+
self.list.addItem(item)
|
|
117
|
+
self.list.setCurrentItem(item)
|
|
118
|
+
self.list.editItem(item)
|
|
119
|
+
|
|
120
|
+
def _delete(self):
|
|
121
|
+
row = self.list.currentRow()
|
|
122
|
+
if row >= 0:
|
|
123
|
+
self.list.takeItem(row)
|
|
124
|
+
|
|
125
|
+
def values(self):
|
|
126
|
+
return [self.list.item(i).text() for i in range(self.list.count())]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SettingsDialog(QDialog):
|
|
130
|
+
"""Edit and immediately apply the MiSleep configuration."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, parent=None):
|
|
133
|
+
super().__init__(parent)
|
|
134
|
+
self._main = parent
|
|
135
|
+
self.config = parent.config
|
|
136
|
+
|
|
137
|
+
self.setWindowTitle("MiSleep settings")
|
|
138
|
+
self.resize(640, 560)
|
|
139
|
+
self.setWindowIcon(app_icon())
|
|
140
|
+
|
|
141
|
+
tabs = QTabWidget()
|
|
142
|
+
tabs.addTab(self._build_states_tab(), "Sleep states")
|
|
143
|
+
tabs.addTab(self._build_colors_tab(), "Colors")
|
|
144
|
+
tabs.addTab(self._build_labels_tab(), "Labels")
|
|
145
|
+
tabs.addTab(self._build_spectral_tab(), "Spectral")
|
|
146
|
+
tabs.addTab(self._build_general_tab(), "General")
|
|
147
|
+
|
|
148
|
+
scroll = QScrollArea()
|
|
149
|
+
scroll.setWidgetResizable(True)
|
|
150
|
+
scroll.setWidget(tabs)
|
|
151
|
+
|
|
152
|
+
btn_row = QHBoxLayout()
|
|
153
|
+
btn_row.addStretch(1)
|
|
154
|
+
open_bt = QPushButton("Open file…")
|
|
155
|
+
open_bt.clicked.connect(self._open_file)
|
|
156
|
+
apply_bt = QPushButton("Apply")
|
|
157
|
+
apply_bt.clicked.connect(self._apply)
|
|
158
|
+
ok_bt = QPushButton("OK")
|
|
159
|
+
ok_bt.clicked.connect(self._ok)
|
|
160
|
+
cancel_bt = QPushButton("Cancel")
|
|
161
|
+
cancel_bt.clicked.connect(self.reject)
|
|
162
|
+
for b in (open_bt, apply_bt, ok_bt, cancel_bt):
|
|
163
|
+
btn_row.addWidget(b)
|
|
164
|
+
|
|
165
|
+
layout = QVBoxLayout(self)
|
|
166
|
+
layout.addWidget(scroll, 1)
|
|
167
|
+
layout.addLayout(btn_row)
|
|
168
|
+
|
|
169
|
+
self._load_from_config()
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Tab builders
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
def _build_states_tab(self):
|
|
175
|
+
self._state_rows = {} # code -> (name_edit, color_button)
|
|
176
|
+
box = QGroupBox("Sleep states")
|
|
177
|
+
form = QFormLayout(box)
|
|
178
|
+
hint = QLabel("State codes, names and background colors used in the "
|
|
179
|
+
"signal panel and hypnogram. Up to 10 states "
|
|
180
|
+
"(keys 0-9 label the selected area).")
|
|
181
|
+
hint.setWordWrap(True)
|
|
182
|
+
form.addRow(hint)
|
|
183
|
+
self._state_frame = QVBoxLayout()
|
|
184
|
+
form.addRow(self._state_frame)
|
|
185
|
+
|
|
186
|
+
btn_row = QHBoxLayout()
|
|
187
|
+
add_bt = QPushButton("Add state")
|
|
188
|
+
rem_bt = QPushButton("Remove state")
|
|
189
|
+
add_bt.setToolTip("Add a state with the next free code (1-10)")
|
|
190
|
+
rem_bt.setToolTip("Remove the highest state")
|
|
191
|
+
add_bt.clicked.connect(self._add_state)
|
|
192
|
+
rem_bt.clicked.connect(self._remove_state)
|
|
193
|
+
btn_row.addWidget(add_bt)
|
|
194
|
+
btn_row.addWidget(rem_bt)
|
|
195
|
+
btn_row.addStretch(1)
|
|
196
|
+
form.addRow(btn_row)
|
|
197
|
+
return box
|
|
198
|
+
|
|
199
|
+
def _add_state_row(self, code, name, color):
|
|
200
|
+
"""Append one state row (code, name edit, color swatch)."""
|
|
201
|
+
row = QWidget()
|
|
202
|
+
h = QHBoxLayout(row)
|
|
203
|
+
h.setContentsMargins(0, 0, 0, 0)
|
|
204
|
+
h.addWidget(QLabel(f"State {code}:"))
|
|
205
|
+
name_edit = QLineEdit(name)
|
|
206
|
+
name_edit.setMinimumWidth(140)
|
|
207
|
+
name_edit.setMaximumWidth(220)
|
|
208
|
+
h.addWidget(name_edit)
|
|
209
|
+
color_bt = ColorButton(color)
|
|
210
|
+
h.addWidget(color_bt)
|
|
211
|
+
h.addStretch(1)
|
|
212
|
+
self._state_frame.addWidget(row)
|
|
213
|
+
self._state_rows[int(code)] = (name_edit, color_bt)
|
|
214
|
+
|
|
215
|
+
def _add_state(self):
|
|
216
|
+
used = set(self._state_rows.keys())
|
|
217
|
+
free = [c for c in range(1, 11) if c not in used]
|
|
218
|
+
if not free:
|
|
219
|
+
QMessageBox.information(self, "MiSleep", "Maximum of 10 states.")
|
|
220
|
+
return
|
|
221
|
+
self._add_state_row(free[0], f"State {free[0]}", "#808080")
|
|
222
|
+
|
|
223
|
+
def _remove_state(self):
|
|
224
|
+
if len(self._state_rows) <= 1:
|
|
225
|
+
QMessageBox.information(self, "MiSleep", "Keep at least one state.")
|
|
226
|
+
return
|
|
227
|
+
code = max(self._state_rows)
|
|
228
|
+
row_widget = self._state_rows[code][0].parentWidget()
|
|
229
|
+
self._state_frame.removeWidget(row_widget)
|
|
230
|
+
row_widget.deleteLater()
|
|
231
|
+
del self._state_rows[code]
|
|
232
|
+
|
|
233
|
+
def _build_colors_tab(self):
|
|
234
|
+
page = QWidget()
|
|
235
|
+
page_layout = QVBoxLayout(page)
|
|
236
|
+
box = QGroupBox("Marker color")
|
|
237
|
+
line_form = QFormLayout(box)
|
|
238
|
+
self._marker_line_bt = ColorButton()
|
|
239
|
+
self._marker_line_bt.setToolTip(
|
|
240
|
+
"Marker color in both the signal panel and hypnogram")
|
|
241
|
+
line_form.addRow("Marker:", self._marker_line_bt)
|
|
242
|
+
page_layout.addWidget(box)
|
|
243
|
+
page_layout.addStretch(1)
|
|
244
|
+
return page
|
|
245
|
+
|
|
246
|
+
def _build_labels_tab(self):
|
|
247
|
+
box = QGroupBox("Label lists")
|
|
248
|
+
layout = QVBoxLayout(box)
|
|
249
|
+
|
|
250
|
+
layout.addWidget(QLabel("Marker labels (single time-point events):"))
|
|
251
|
+
self._marker_editor = _ListEditor([])
|
|
252
|
+
layout.addWidget(self._marker_editor)
|
|
253
|
+
|
|
254
|
+
layout.addWidget(QLabel("Start-End labels (events with a duration):"))
|
|
255
|
+
self._se_editor = _ListEditor([])
|
|
256
|
+
layout.addWidget(self._se_editor)
|
|
257
|
+
return box
|
|
258
|
+
|
|
259
|
+
def _build_spectral_tab(self):
|
|
260
|
+
box = QGroupBox("Spectral analysis defaults")
|
|
261
|
+
form = QFormLayout(box)
|
|
262
|
+
|
|
263
|
+
self._freq_low = QDoubleSpinBox()
|
|
264
|
+
self._freq_low.setRange(0, 500)
|
|
265
|
+
self._freq_low.setDecimals(2)
|
|
266
|
+
self._freq_high = QDoubleSpinBox()
|
|
267
|
+
self._freq_high.setRange(0, 500)
|
|
268
|
+
self._freq_high.setDecimals(2)
|
|
269
|
+
form.addRow("Frequency range (Hz):", self._freq_low)
|
|
270
|
+
form.addRow("", self._freq_high)
|
|
271
|
+
|
|
272
|
+
self._win_length = QDoubleSpinBox()
|
|
273
|
+
self._win_length.setRange(1, 3600)
|
|
274
|
+
self._win_length.setDecimals(1)
|
|
275
|
+
form.addRow("Window length (s):", self._win_length)
|
|
276
|
+
|
|
277
|
+
self._nfft = QDoubleSpinBox()
|
|
278
|
+
self._nfft.setRange(1, 3600)
|
|
279
|
+
self._nfft.setDecimals(1)
|
|
280
|
+
form.addRow("nfft (s):", self._nfft)
|
|
281
|
+
|
|
282
|
+
self._gaussian = QDoubleSpinBox()
|
|
283
|
+
self._gaussian.setRange(0, 100)
|
|
284
|
+
self._gaussian.setDecimals(2)
|
|
285
|
+
form.addRow("Gaussian smoothing σ:", self._gaussian)
|
|
286
|
+
return box
|
|
287
|
+
|
|
288
|
+
def _build_general_tab(self):
|
|
289
|
+
box = QGroupBox("General")
|
|
290
|
+
form = QFormLayout(box)
|
|
291
|
+
|
|
292
|
+
self._bg_alpha = QDoubleSpinBox()
|
|
293
|
+
self._bg_alpha.setRange(0, 1)
|
|
294
|
+
self._bg_alpha.setSingleStep(0.05)
|
|
295
|
+
self._bg_alpha.setDecimals(2)
|
|
296
|
+
form.addRow("State background alpha:", self._bg_alpha)
|
|
297
|
+
|
|
298
|
+
self._hypno_alpha = QDoubleSpinBox()
|
|
299
|
+
self._hypno_alpha.setRange(0.15, 1.0)
|
|
300
|
+
self._hypno_alpha.setSingleStep(0.05)
|
|
301
|
+
self._hypno_alpha.setDecimals(2)
|
|
302
|
+
self._hypno_alpha.setToolTip(
|
|
303
|
+
"Lower values make state bands lighter so marker lines remain visible.")
|
|
304
|
+
form.addRow("Hypnogram state alpha:", self._hypno_alpha)
|
|
305
|
+
|
|
306
|
+
path_row = QHBoxLayout()
|
|
307
|
+
self._openpath = QLineEdit()
|
|
308
|
+
browse_bt = QPushButton("Browse…")
|
|
309
|
+
browse_bt.clicked.connect(self._browse_path)
|
|
310
|
+
path_row.addWidget(self._openpath, 1)
|
|
311
|
+
path_row.addWidget(browse_bt)
|
|
312
|
+
form.addRow("Default open path:", path_row)
|
|
313
|
+
|
|
314
|
+
self._theme_combo = QComboBox()
|
|
315
|
+
self._theme_combo.addItems(["light", "dark"])
|
|
316
|
+
self._theme_combo.setToolTip(
|
|
317
|
+
"Applies immediately: widgets and plots switch together.")
|
|
318
|
+
form.addRow("Theme:", self._theme_combo)
|
|
319
|
+
|
|
320
|
+
self._tone_combo = QComboBox()
|
|
321
|
+
self._tone_combo.addItems(["black", "pink", "blue", "khaki"])
|
|
322
|
+
self._tone_combo.setToolTip(
|
|
323
|
+
"Chrome / accent color scheme (independent of light/dark mode).")
|
|
324
|
+
form.addRow("Color scheme:", self._tone_combo)
|
|
325
|
+
|
|
326
|
+
return box
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# Load / collect / save
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
def _load_from_config(self):
|
|
332
|
+
gui = self.config["gui"]
|
|
333
|
+
spec = self.config["spec"]
|
|
334
|
+
|
|
335
|
+
# Sleep states (dynamic: one row per state code, up to 10)
|
|
336
|
+
while self._state_frame.count():
|
|
337
|
+
item = self._state_frame.takeAt(0)
|
|
338
|
+
if item.widget():
|
|
339
|
+
item.widget().deleteLater()
|
|
340
|
+
statemap = json.loads(gui["statemap"])
|
|
341
|
+
statecolor = json.loads(gui["statecolor"])
|
|
342
|
+
for code in sorted(statemap, key=int):
|
|
343
|
+
self._add_state_row(int(code), str(statemap[code]),
|
|
344
|
+
str(statecolor.get(code, "#808080")))
|
|
345
|
+
|
|
346
|
+
# Label lists
|
|
347
|
+
self._marker_editor.list.clear()
|
|
348
|
+
self._marker_editor.list.addItems(
|
|
349
|
+
[each[1:-1] for each in gui["marker"][1:-1].split(", ")])
|
|
350
|
+
self._se_editor.list.clear()
|
|
351
|
+
self._se_editor.list.addItems(
|
|
352
|
+
[each[1:-1] for each in gui["startend"][1:-1].split(", ")])
|
|
353
|
+
|
|
354
|
+
# Spectral
|
|
355
|
+
low, high = (float(x) for x in gui["freq_range"].strip("[]").split(","))
|
|
356
|
+
self._freq_low.setValue(low)
|
|
357
|
+
self._freq_high.setValue(high)
|
|
358
|
+
self._win_length.setValue(float(spec["win_length_sec"]))
|
|
359
|
+
self._nfft.setValue(float(spec["nfft_sec"]))
|
|
360
|
+
self._gaussian.setValue(float(spec["gaussian_sigma"]))
|
|
361
|
+
|
|
362
|
+
# General
|
|
363
|
+
self._bg_alpha.setValue(float(gui["statecolorbgalpha"]))
|
|
364
|
+
self._hypno_alpha.setValue(float(gui.get("hypnogramstatealpha", "0.55")))
|
|
365
|
+
self._openpath.setText(gui["openpath"])
|
|
366
|
+
self._theme_combo.setCurrentText(gui.get("theme", "light"))
|
|
367
|
+
self._tone_combo.setCurrentText(gui.get("color_tone", "black"))
|
|
368
|
+
self._marker_line_bt.set_color(
|
|
369
|
+
gui.get("markerlinecolor", "red").strip("\"'"))
|
|
370
|
+
|
|
371
|
+
def _browse_path(self):
|
|
372
|
+
path = QFileDialog.getExistingDirectory(self, "Select default folder",
|
|
373
|
+
self._openpath.text())
|
|
374
|
+
if path:
|
|
375
|
+
self._openpath.setText(path)
|
|
376
|
+
|
|
377
|
+
def _open_file(self):
|
|
378
|
+
if self._main is not None:
|
|
379
|
+
self._main.pupup_config()
|
|
380
|
+
|
|
381
|
+
# ------------------------------------------------------------------
|
|
382
|
+
# Apply / save
|
|
383
|
+
# ------------------------------------------------------------------
|
|
384
|
+
def _collect(self) -> dict:
|
|
385
|
+
"""Return {section: {key: value}} with the new configuration."""
|
|
386
|
+
statemap = {str(code): name_edit.text().strip()
|
|
387
|
+
for code, (name_edit, _) in self._state_rows.items()}
|
|
388
|
+
statecolor = {str(code): bt.color().name()
|
|
389
|
+
for code, (_, bt) in self._state_rows.items()}
|
|
390
|
+
return {
|
|
391
|
+
"gui": {
|
|
392
|
+
"statemap": json.dumps(statemap),
|
|
393
|
+
"statecolor": json.dumps(statecolor),
|
|
394
|
+
"marker": str(self._marker_editor.values()),
|
|
395
|
+
"startend": str(self._se_editor.values()),
|
|
396
|
+
"statecolorbgalpha": str(self._bg_alpha.value()),
|
|
397
|
+
"hypnogramstatealpha": str(self._hypno_alpha.value()),
|
|
398
|
+
"freq_range": json.dumps([self._freq_low.value(), self._freq_high.value()]),
|
|
399
|
+
"openpath": self._openpath.text(),
|
|
400
|
+
"theme": self._theme_combo.currentText(),
|
|
401
|
+
"color_tone": self._tone_combo.currentText(),
|
|
402
|
+
"spectrogram_cmap": "jet",
|
|
403
|
+
"markerlinecolor": self._marker_line_bt.color().name(),
|
|
404
|
+
},
|
|
405
|
+
"spec": {
|
|
406
|
+
"win_length_sec": str(self._win_length.value()),
|
|
407
|
+
"nfft_sec": str(self._nfft.value()),
|
|
408
|
+
"gaussian_sigma": str(self._gaussian.value()),
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def _save(self):
|
|
413
|
+
for section, values in self._collect().items():
|
|
414
|
+
for key, value in values.items():
|
|
415
|
+
self.config.set(section, key, value)
|
|
416
|
+
save_config(self.config)
|
|
417
|
+
|
|
418
|
+
def _apply(self):
|
|
419
|
+
self._save()
|
|
420
|
+
if self._main is not None:
|
|
421
|
+
self._main.apply_settings()
|
|
422
|
+
QMessageBox.information(self, "MiSleep",
|
|
423
|
+
"Settings saved and applied.")
|
|
424
|
+
|
|
425
|
+
def _ok(self):
|
|
426
|
+
self._save()
|
|
427
|
+
if self._main is not None:
|
|
428
|
+
self._main.apply_settings()
|
|
429
|
+
self.accept()
|