accusleepy 0.1.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.
@@ -0,0 +1,356 @@
1
+ # Widget with a matplotlib FigureCanvas for manual scoring
2
+ from collections.abc import Callable
3
+
4
+ import matplotlib.ticker as mticker
5
+ import numpy as np
6
+ from matplotlib.backend_bases import MouseButton
7
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
8
+ from matplotlib.figure import Figure
9
+ from matplotlib.gridspec import GridSpec
10
+ from matplotlib.patches import Rectangle
11
+ from matplotlib.widgets import RectangleSelector
12
+
13
+ # from PySide6.QtWidgets import *
14
+ from PySide6 import QtWidgets
15
+
16
+ from accusleepy.brain_state_set import BrainStateSet
17
+
18
+ # upper limit of spectrogram y-axis, in Hz
19
+ SPEC_UPPER_F = 30
20
+ # interval of spectrogram y-axis ticks, in Hz
21
+ SPEC_Y_TICK_INTERVAL = 10
22
+
23
+ # margins around subplots in the figure
24
+ SUBPLOT_TOP_MARGIN = 0.98
25
+ SUBPLOT_BOTTOM_MARGIN = 0.02
26
+ SUBPLOT_LEFT_MARGIN = 0.07
27
+ SUBPLOT_RIGHT_MARGIN = 0.95
28
+
29
+ # maximum number of x-axis ticks to show on the lower plot
30
+ MAX_LOWER_X_TICK_N = 7
31
+
32
+
33
+ class MplWidget(QtWidgets.QWidget):
34
+ """Widget that displays a matplotlib FigureCanvas"""
35
+
36
+ def __init__(self, parent=None):
37
+ QtWidgets.QWidget.__init__(self, parent)
38
+ # set up the canvas and store a reference to its axes
39
+ self.canvas = FigureCanvas(Figure())
40
+ self.canvas.axes = None
41
+
42
+ # set the widget layout and remove the margins
43
+ vertical_layout = QtWidgets.QVBoxLayout()
44
+ vertical_layout.addWidget(self.canvas)
45
+ vertical_layout.setContentsMargins(0, 0, 0, 0)
46
+ self.setLayout(vertical_layout)
47
+
48
+ # given during the setup process
49
+ self.epoch_length = None
50
+
51
+ # upper plot references
52
+ self.upper_marker = None
53
+ self.label_img_ref = None
54
+ self.spec_ref = None
55
+ self.roi = None
56
+ self.editing_patch = None
57
+ self.roi_patch = None
58
+
59
+ # lower plot references
60
+ self.eeg_line = None
61
+ self.emg_line = None
62
+ self.top_marker = None
63
+ self.bottom_marker = None
64
+
65
+ def setup_upper_figure(
66
+ self,
67
+ n_epochs: int,
68
+ label_img: np.array,
69
+ spec: np.array,
70
+ f: np.array,
71
+ emg: np.array,
72
+ epochs_to_show: int,
73
+ label_display_options: np.array,
74
+ brain_state_set: BrainStateSet,
75
+ roi_function: Callable,
76
+ ):
77
+ """Initialize upper FigureCanvas for the manual scoring GUI
78
+
79
+ :param n_epochs: number of epochs in the recording
80
+ :param label_img: brain state labels, as an image
81
+ :param spec: EEG spectrogram image
82
+ :param f: EEG spectrogram frequency axis
83
+ :param emg: EMG RMS per epoch
84
+ :param epochs_to_show: number of epochs to show in the lower plot
85
+ :param label_display_options: valid brain state y-axis locations
86
+ :param brain_state_set: set of brain states options
87
+ :param roi_function: callback for ROI selection
88
+ """
89
+ # references to parts of the epoch marker
90
+ self.upper_marker = list()
91
+
92
+ # subplot layout
93
+ height_ratios = [8, 2, 12, 13]
94
+ gs1 = GridSpec(4, 1, hspace=0, height_ratios=height_ratios)
95
+ gs2 = GridSpec(4, 1, hspace=0.4, height_ratios=height_ratios)
96
+ axes = list()
97
+ axes.append(self.canvas.figure.add_subplot(gs1[0]))
98
+ axes.append(self.canvas.figure.add_subplot(gs1[1]))
99
+ axes.append(self.canvas.figure.add_subplot(gs1[2]))
100
+ axes.append(self.canvas.figure.add_subplot(gs2[3]))
101
+
102
+ # subplots have different axes limits
103
+ for i in [0, 1, 3]:
104
+ axes[i].set_xlim((-0.5, n_epochs - 0.5))
105
+ axes[2].set_xlim(0, n_epochs)
106
+
107
+ # brain state subplot
108
+ axes[0].set_ylim(
109
+ [-0.5, np.max(label_display_options) - np.min(label_display_options) + 0.5]
110
+ )
111
+ axes[0].set_xticks([])
112
+ axes[0].set_yticks(
113
+ label_display_options - np.min(label_display_options),
114
+ )
115
+ axes[0].set_yticklabels([b.name for b in brain_state_set.brain_states])
116
+ ax2 = axes[0].secondary_yaxis("right")
117
+ ax2.set_yticks(
118
+ label_display_options - np.min(label_display_options),
119
+ )
120
+ ax2.set_yticklabels([b.digit for b in brain_state_set.brain_states])
121
+ self.label_img_ref = axes[0].imshow(
122
+ label_img, aspect="auto", origin="lower", interpolation="None"
123
+ )
124
+ # add patch to dim the display when creating an ROI
125
+ self.editing_patch = axes[0].add_patch(
126
+ Rectangle(
127
+ xy=(-0.5, -0.5),
128
+ width=n_epochs,
129
+ height=np.max(label_display_options)
130
+ - np.min(label_display_options)
131
+ + 1,
132
+ color="white",
133
+ edgecolor=None,
134
+ alpha=0.4,
135
+ visible=False,
136
+ )
137
+ )
138
+ # add the ROI selection widget, but disable it until it's needed
139
+ self.roi = RectangleSelector(
140
+ ax=axes[0],
141
+ onselect=roi_function,
142
+ interactive=False,
143
+ button=MouseButton(1),
144
+ )
145
+ self.roi.set_active(False)
146
+ # keep a reference to the ROI patch so we can change its color later
147
+ # index 0 is the "editing_patch" created earlier
148
+ self.roi_patch = [c for c in axes[0].get_children() if type(c) is Rectangle][1]
149
+
150
+ # epoch marker subplot
151
+ axes[1].set_ylim((0, 1))
152
+ axes[1].axis("off")
153
+ self.upper_marker.append(
154
+ axes[1].plot([-0.5, epochs_to_show - 0.5], [0.5, 0.5], "r")[0]
155
+ )
156
+ self.upper_marker.append(axes[1].plot([0], [0.5], "rD")[0])
157
+
158
+ # EEG spectrogram subplot
159
+ # select subset of frequencies to show
160
+ f = f[f <= SPEC_UPPER_F]
161
+ spec = spec[0 : len(f), :]
162
+ axes[2].set_ylabel("EEG", rotation="horizontal", ha="right")
163
+ axes[2].set_yticks(
164
+ np.linspace(
165
+ 0,
166
+ len(f),
167
+ 1 + round(SPEC_UPPER_F / SPEC_Y_TICK_INTERVAL),
168
+ ),
169
+ )
170
+ axes[2].set_yticklabels(
171
+ np.arange(0, SPEC_UPPER_F + SPEC_Y_TICK_INTERVAL, SPEC_Y_TICK_INTERVAL)
172
+ )
173
+ axes[2].tick_params(axis="both", which="major", labelsize=8)
174
+ axes[2].xaxis.set_major_formatter(mticker.FuncFormatter(self.time_formatter))
175
+ self.spec_ref = axes[2].imshow(
176
+ spec,
177
+ vmin=np.percentile(spec, 2),
178
+ vmax=np.percentile(spec, 98),
179
+ aspect="auto",
180
+ origin="lower",
181
+ interpolation="None",
182
+ extent=(
183
+ 0,
184
+ n_epochs,
185
+ -0.5,
186
+ len(f) + 0.5,
187
+ ),
188
+ )
189
+
190
+ # EMG subplot
191
+ axes[3].set_xticks([])
192
+ axes[3].set_yticks([])
193
+ axes[3].set_ylabel("EMG", rotation="horizontal", ha="right")
194
+ axes[3].plot(
195
+ emg,
196
+ "k",
197
+ linewidth=0.5,
198
+ )
199
+
200
+ self.canvas.figure.subplots_adjust(
201
+ left=SUBPLOT_LEFT_MARGIN,
202
+ right=SUBPLOT_RIGHT_MARGIN,
203
+ top=SUBPLOT_TOP_MARGIN,
204
+ bottom=SUBPLOT_BOTTOM_MARGIN,
205
+ )
206
+
207
+ self.canvas.axes = axes
208
+
209
+ def setup_lower_figure(
210
+ self,
211
+ label_img: np.array,
212
+ sampling_rate: int | float,
213
+ epochs_to_show: int,
214
+ brain_state_set: BrainStateSet,
215
+ label_display_options: np.array,
216
+ ):
217
+ """Initialize lower FigureCanvas for the manual scoring GUI
218
+
219
+ :param label_img: brain state labels, as an image
220
+ :param sampling_rate: EEG/EMG sampling rate, in Hz
221
+ :param epochs_to_show: number of epochs to show in the lower plot
222
+ :param brain_state_set: set of brain states options
223
+ :param label_display_options: valid brain state y-axis locations
224
+ """
225
+ # number of samples in one epoch
226
+ # we can expect this to be very close to an integer
227
+ samples_per_epoch = round(sampling_rate * self.epoch_length)
228
+ # number of EEG/EMG samples to plot
229
+ samples_shown = round(samples_per_epoch * epochs_to_show)
230
+
231
+ # references to parts of the epoch markers
232
+ self.top_marker = list()
233
+ self.bottom_marker = list()
234
+ # epoch marker display parameters
235
+ marker_dy = 0.25
236
+ marker_y_offset_top = 0.02
237
+ marker_y_offset_bottom = 0.01
238
+
239
+ # subplot layout
240
+ gs1 = GridSpec(3, 1, hspace=0)
241
+ gs2 = GridSpec(3, 1, hspace=0.5)
242
+ axes = list()
243
+ axes.append(self.canvas.figure.add_subplot(gs1[0]))
244
+ axes.append(self.canvas.figure.add_subplot(gs1[1]))
245
+ axes.append(self.canvas.figure.add_subplot(gs2[2]))
246
+
247
+ # EEG subplot
248
+ axes[0].set_xticks([])
249
+ axes[0].set_yticks([])
250
+ axes[0].set_xlim((0, samples_shown))
251
+ axes[0].set_ylim((-1, 1))
252
+ axes[0].set_ylabel("EEG", rotation="horizontal", ha="right")
253
+ self.eeg_line = axes[0].plot(
254
+ np.zeros(samples_shown),
255
+ "k",
256
+ linewidth=0.5,
257
+ )[0]
258
+ # top epoch marker
259
+ marker_x = [
260
+ [0, 0],
261
+ [0, samples_per_epoch],
262
+ [samples_per_epoch, samples_per_epoch],
263
+ ]
264
+ marker_y = np.array(
265
+ [
266
+ [1 - marker_dy, 1],
267
+ [1, 1],
268
+ [1 - marker_dy, 1],
269
+ ]
270
+ )
271
+ for x, y in zip(marker_x, marker_y):
272
+ self.top_marker.append(axes[0].plot(x, y - marker_y_offset_top, "r")[0])
273
+
274
+ # EMG subplot
275
+ axes[1].set_xticks(
276
+ resample_x_ticks(
277
+ np.arange(
278
+ 0,
279
+ samples_shown,
280
+ samples_per_epoch,
281
+ )
282
+ )
283
+ )
284
+ axes[1].tick_params(axis="x", which="major", labelsize=8)
285
+ axes[1].set_yticks([])
286
+ axes[1].set_xlim((0, samples_shown))
287
+ axes[1].set_ylim((-1, 1))
288
+ axes[1].set_ylabel("EMG", rotation="horizontal", ha="right")
289
+ self.emg_line = axes[1].plot(
290
+ np.zeros(samples_shown),
291
+ "k",
292
+ linewidth=0.5,
293
+ )[0]
294
+
295
+ for x, y in zip(marker_x, marker_y):
296
+ self.bottom_marker.append(
297
+ axes[1].plot(x, -1 * (y - marker_y_offset_bottom), "r")[0]
298
+ )
299
+
300
+ # brain state subplot
301
+ axes[2].set_xticks([])
302
+ axes[2].set_yticks(
303
+ label_display_options - np.min(label_display_options),
304
+ )
305
+ axes[2].set_yticklabels([b.name for b in brain_state_set.brain_states])
306
+ ax2 = axes[2].secondary_yaxis("right")
307
+ ax2.set_yticks(
308
+ label_display_options - np.min(label_display_options),
309
+ )
310
+ ax2.set_yticklabels([b.digit for b in brain_state_set.brain_states])
311
+ axes[2].set_xlim((-0.5, epochs_to_show - 0.5))
312
+ axes[2].set_ylim(
313
+ [-0.5, np.max(label_display_options) - np.min(label_display_options) + 0.5]
314
+ )
315
+ self.label_img_ref = axes[2].imshow(
316
+ label_img[:, 0:epochs_to_show, :],
317
+ aspect="auto",
318
+ origin="lower",
319
+ interpolation="None",
320
+ )
321
+
322
+ self.canvas.figure.subplots_adjust(
323
+ left=SUBPLOT_LEFT_MARGIN,
324
+ right=SUBPLOT_RIGHT_MARGIN,
325
+ top=SUBPLOT_TOP_MARGIN,
326
+ bottom=SUBPLOT_BOTTOM_MARGIN,
327
+ )
328
+
329
+ self.canvas.axes = axes
330
+
331
+ def time_formatter(self, x, pos):
332
+ x = x * self.epoch_length
333
+ return "{:02d}:{:02d}:{:05.2f}".format(
334
+ int(x // 3600), int(x // 60) % 60, (x % 60)
335
+ )
336
+
337
+
338
+ def resample_x_ticks(x_ticks: np.array) -> np.array:
339
+ """Choose a subset of x_ticks to display
340
+
341
+ The x-axis can get crowded if there are too many timestamps shown.
342
+ This function resamples the x-axis ticks by a factor of either
343
+ MAX_LOWER_X_TICK_N or MAX_LOWER_X_TICK_N - 2, whichever is closer
344
+ to being a factor of the number of ticks.
345
+
346
+ :param x_ticks: full set of x_ticks
347
+ :return: smaller subset of x_ticks
348
+ """
349
+ # add one since the tick at the rightmost edge isn't shown
350
+ n_ticks = len(x_ticks) + 1
351
+ if n_ticks < MAX_LOWER_X_TICK_N:
352
+ return x_ticks
353
+ elif n_ticks % MAX_LOWER_X_TICK_N < n_ticks % (MAX_LOWER_X_TICK_N - 2):
354
+ return x_ticks[:: int(n_ticks / MAX_LOWER_X_TICK_N)]
355
+ else:
356
+ return x_ticks[:: int(n_ticks / (MAX_LOWER_X_TICK_N - 2))]