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.
- accusleepy/__init__.py +0 -0
- accusleepy/__main__.py +4 -0
- accusleepy/brain_state_set.py +89 -0
- accusleepy/classification.py +267 -0
- accusleepy/config.json +22 -0
- accusleepy/constants.py +37 -0
- accusleepy/fileio.py +201 -0
- accusleepy/gui/__init__.py +0 -0
- accusleepy/gui/icons/brightness_down.png +0 -0
- accusleepy/gui/icons/brightness_up.png +0 -0
- accusleepy/gui/icons/double_down_arrow.png +0 -0
- accusleepy/gui/icons/double_up_arrow.png +0 -0
- accusleepy/gui/icons/down_arrow.png +0 -0
- accusleepy/gui/icons/home.png +0 -0
- accusleepy/gui/icons/question.png +0 -0
- accusleepy/gui/icons/save.png +0 -0
- accusleepy/gui/icons/up_arrow.png +0 -0
- accusleepy/gui/icons/zoom_in.png +0 -0
- accusleepy/gui/icons/zoom_out.png +0 -0
- accusleepy/gui/main.py +1372 -0
- accusleepy/gui/manual_scoring.py +1086 -0
- accusleepy/gui/mplwidget.py +356 -0
- accusleepy/gui/primary_window.py +2330 -0
- accusleepy/gui/primary_window.ui +3432 -0
- accusleepy/gui/resources.qrc +16 -0
- accusleepy/gui/resources_rc.py +6710 -0
- accusleepy/gui/text/config_guide.txt +24 -0
- accusleepy/gui/text/main_guide.txt +142 -0
- accusleepy/gui/text/manual_scoring_guide.txt +28 -0
- accusleepy/gui/viewer_window.py +598 -0
- accusleepy/gui/viewer_window.ui +894 -0
- accusleepy/models.py +48 -0
- accusleepy/multitaper.py +659 -0
- accusleepy/signal_processing.py +589 -0
- accusleepy-0.1.0.dist-info/METADATA +57 -0
- accusleepy-0.1.0.dist-info/RECORD +37 -0
- accusleepy-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import warnings
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from operator import attrgetter
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from PIL import Image
|
|
10
|
+
from scipy.signal import butter, filtfilt
|
|
11
|
+
from tqdm import trange
|
|
12
|
+
|
|
13
|
+
from accusleepy.brain_state_set import BrainStateSet
|
|
14
|
+
from accusleepy.constants import (
|
|
15
|
+
DEFAULT_MODEL_TYPE,
|
|
16
|
+
DOWNSAMPLING_START_FREQ,
|
|
17
|
+
EMG_COPIES,
|
|
18
|
+
FILENAME_COL,
|
|
19
|
+
LABEL_COL,
|
|
20
|
+
MIN_WINDOW_LEN,
|
|
21
|
+
UPPER_FREQ,
|
|
22
|
+
)
|
|
23
|
+
from accusleepy.fileio import Recording, load_labels, load_recording
|
|
24
|
+
from accusleepy.multitaper import spectrogram
|
|
25
|
+
|
|
26
|
+
# clip mixture z-scores above and below this level
|
|
27
|
+
# in the matlab implementation, I used 4.5
|
|
28
|
+
ABS_MAX_Z_SCORE = 3.5
|
|
29
|
+
# upper frequency limit when generating EEG spectrograms
|
|
30
|
+
SPECTROGRAM_UPPER_FREQ = 64
|
|
31
|
+
# filename used to store info about training image datasets
|
|
32
|
+
ANNOTATIONS_FILENAME = "annotations.csv"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resample(
|
|
36
|
+
eeg: np.array, emg: np.array, sampling_rate: int | float, epoch_length: int | float
|
|
37
|
+
) -> (np.array, np.array, float):
|
|
38
|
+
"""Resample recording so that epochs contain equal numbers of samples
|
|
39
|
+
|
|
40
|
+
If the number of samples per epoch is not an integer, epoch-level calculations
|
|
41
|
+
are much more difficult. To avoid this, we can resample the EEG and EMG signals
|
|
42
|
+
and adjust the sampling rate accordingly.
|
|
43
|
+
|
|
44
|
+
:param eeg: EEG signal
|
|
45
|
+
:param emg: EMG signal
|
|
46
|
+
:param sampling_rate: original sampling rate, in Hz
|
|
47
|
+
:param epoch_length: epoch length, in seconds
|
|
48
|
+
:return: resampled EEG & EMG and updated sampling rate
|
|
49
|
+
"""
|
|
50
|
+
samples_per_epoch = sampling_rate * epoch_length
|
|
51
|
+
if samples_per_epoch % 1 == 0:
|
|
52
|
+
return eeg, emg, sampling_rate
|
|
53
|
+
|
|
54
|
+
resampled = list()
|
|
55
|
+
for arr in [eeg, emg]:
|
|
56
|
+
x = np.arange(0, arr.size)
|
|
57
|
+
x_new = np.linspace(
|
|
58
|
+
0,
|
|
59
|
+
arr.size - 1,
|
|
60
|
+
round(arr.size * np.ceil(samples_per_epoch) / samples_per_epoch),
|
|
61
|
+
)
|
|
62
|
+
resampled.append(np.interp(x_new, x, arr))
|
|
63
|
+
|
|
64
|
+
eeg = resampled[0]
|
|
65
|
+
emg = resampled[1]
|
|
66
|
+
new_sampling_rate = np.ceil(samples_per_epoch) / samples_per_epoch * sampling_rate
|
|
67
|
+
return eeg, emg, new_sampling_rate
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def standardize_signal_length(
|
|
71
|
+
eeg: np.array, emg: np.array, sampling_rate: int | float, epoch_length: int | float
|
|
72
|
+
) -> (np.array, np.array):
|
|
73
|
+
"""Truncate or pad EEG/EMG signals to have an integer number of epochs
|
|
74
|
+
|
|
75
|
+
:param eeg: EEG signal
|
|
76
|
+
:param emg: EMG signal
|
|
77
|
+
:param sampling_rate: original sampling rate, in Hz
|
|
78
|
+
:param epoch_length: epoch length, in seconds
|
|
79
|
+
:return: EEG and EMG signals
|
|
80
|
+
"""
|
|
81
|
+
# since resample() was called, this will be extremely close to an integer
|
|
82
|
+
samples_per_epoch = round(sampling_rate * epoch_length)
|
|
83
|
+
|
|
84
|
+
# pad the signal at the end in case we need more samples
|
|
85
|
+
eeg = np.concatenate((eeg, np.ones(samples_per_epoch) * eeg[-1]))
|
|
86
|
+
emg = np.concatenate((emg, np.ones(samples_per_epoch) * emg[-1]))
|
|
87
|
+
padded_signal_length = eeg.size
|
|
88
|
+
|
|
89
|
+
# count samples that don't fit in any epoch
|
|
90
|
+
excess_samples = padded_signal_length % samples_per_epoch
|
|
91
|
+
# we will definitely remove those
|
|
92
|
+
last_index = padded_signal_length - excess_samples
|
|
93
|
+
# and if the last epoch of real data had more than half of
|
|
94
|
+
# its samples missing, delete it
|
|
95
|
+
if excess_samples < samples_per_epoch / 2:
|
|
96
|
+
last_index -= samples_per_epoch
|
|
97
|
+
|
|
98
|
+
return eeg[:last_index], emg[:last_index]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resample_and_standardize(
|
|
102
|
+
eeg: np.array, emg: np.array, sampling_rate: int | float, epoch_length: int | float
|
|
103
|
+
) -> (np.array, np.array, float):
|
|
104
|
+
"""Preprocess EEG and EMG signals
|
|
105
|
+
|
|
106
|
+
Adjust the length and sampling rate of the EEG and EMG signals so that
|
|
107
|
+
each epoch contains an integer number of samples and each recording
|
|
108
|
+
contains an integer number of epochs.
|
|
109
|
+
|
|
110
|
+
:param eeg: EEG signal
|
|
111
|
+
:param emg: EMG signal
|
|
112
|
+
:param sampling_rate: sampling rate, in Hz
|
|
113
|
+
:param epoch_length: epoch length, in seconds
|
|
114
|
+
:return: processed EEG & EMG signals, and the new sampling rate
|
|
115
|
+
"""
|
|
116
|
+
eeg, emg, sampling_rate = resample(
|
|
117
|
+
eeg=eeg, emg=emg, sampling_rate=sampling_rate, epoch_length=epoch_length
|
|
118
|
+
)
|
|
119
|
+
eeg, emg = standardize_signal_length(
|
|
120
|
+
eeg=eeg, emg=emg, sampling_rate=sampling_rate, epoch_length=epoch_length
|
|
121
|
+
)
|
|
122
|
+
return eeg, emg, sampling_rate
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_spectrogram(
|
|
126
|
+
eeg: np.array,
|
|
127
|
+
sampling_rate: int | float,
|
|
128
|
+
epoch_length: int | float,
|
|
129
|
+
time_bandwidth=2,
|
|
130
|
+
n_tapers=3,
|
|
131
|
+
) -> (np.array, np.array):
|
|
132
|
+
"""Create an EEG spectrogram image
|
|
133
|
+
|
|
134
|
+
:param eeg: EEG signal
|
|
135
|
+
:param sampling_rate: sampling rate, in Hz
|
|
136
|
+
:param epoch_length: epoch length, in seconds
|
|
137
|
+
:param time_bandwidth: time-half bandwidth product
|
|
138
|
+
:param n_tapers: number of DPSS tapers to use
|
|
139
|
+
:return: spectrogram and its frequency axis
|
|
140
|
+
"""
|
|
141
|
+
window_length_sec = max(MIN_WINDOW_LEN, epoch_length)
|
|
142
|
+
# pad the EEG signal so that the first spectrogram window is centered
|
|
143
|
+
# on the first epoch
|
|
144
|
+
# it's possible there's some jank here, if this isn't close to an integer
|
|
145
|
+
pad_length = round((sampling_rate * (window_length_sec - epoch_length) / 2))
|
|
146
|
+
padded_eeg = np.concatenate(
|
|
147
|
+
[eeg[:pad_length][::-1], eeg, eeg[(len(eeg) - pad_length) :][::-1]]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
spec, _, f = spectrogram(
|
|
151
|
+
padded_eeg,
|
|
152
|
+
sampling_rate,
|
|
153
|
+
frequency_range=[0, SPECTROGRAM_UPPER_FREQ],
|
|
154
|
+
time_bandwidth=time_bandwidth,
|
|
155
|
+
num_tapers=n_tapers,
|
|
156
|
+
window_params=[window_length_sec, epoch_length],
|
|
157
|
+
min_nfft=0,
|
|
158
|
+
detrend_opt="off",
|
|
159
|
+
multiprocess=True,
|
|
160
|
+
plot_on=False,
|
|
161
|
+
return_fig=False,
|
|
162
|
+
verbose=False,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# resample frequencies for consistency
|
|
166
|
+
target_frequencies = np.arange(0, SPECTROGRAM_UPPER_FREQ, 1 / MIN_WINDOW_LEN)
|
|
167
|
+
freq_idx = list()
|
|
168
|
+
for i in target_frequencies:
|
|
169
|
+
freq_idx.append(np.argmin(np.abs(f - i)))
|
|
170
|
+
f = f[freq_idx]
|
|
171
|
+
spec = spec[freq_idx, :]
|
|
172
|
+
|
|
173
|
+
return spec, f
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_emg_power(
|
|
177
|
+
emg: np.array, sampling_rate: int | float, epoch_length: int | float
|
|
178
|
+
) -> np.array:
|
|
179
|
+
"""Calculate EMG power for each epoch
|
|
180
|
+
|
|
181
|
+
This applies a 20-50 Hz bandpass filter to the EMG, calculates the RMS
|
|
182
|
+
in each epoch, and takes the log of the result.
|
|
183
|
+
|
|
184
|
+
:param emg: EMG signal
|
|
185
|
+
:param sampling_rate: sampling rate, in Hz
|
|
186
|
+
:param epoch_length: epoch length, in seconds
|
|
187
|
+
:return: EMG "power" for each epoch
|
|
188
|
+
"""
|
|
189
|
+
# filter parameters
|
|
190
|
+
order = 8
|
|
191
|
+
bp_lower = 20
|
|
192
|
+
bp_upper = 50
|
|
193
|
+
|
|
194
|
+
b, a = butter(
|
|
195
|
+
N=order,
|
|
196
|
+
Wn=[bp_lower, bp_upper],
|
|
197
|
+
btype="bandpass",
|
|
198
|
+
output="ba",
|
|
199
|
+
fs=sampling_rate,
|
|
200
|
+
)
|
|
201
|
+
filtered = filtfilt(b, a, x=emg, padlen=int(np.ceil(sampling_rate)))
|
|
202
|
+
|
|
203
|
+
# since resample() was called, this will be extremely close to an integer
|
|
204
|
+
samples_per_epoch = round(sampling_rate * epoch_length)
|
|
205
|
+
reshaped = np.reshape(
|
|
206
|
+
filtered,
|
|
207
|
+
[round(len(emg) / samples_per_epoch), samples_per_epoch],
|
|
208
|
+
)
|
|
209
|
+
rms = np.sqrt(np.mean(np.power(reshaped, 2), axis=1))
|
|
210
|
+
|
|
211
|
+
return np.log(rms)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def create_eeg_emg_image(
|
|
215
|
+
eeg: np.array,
|
|
216
|
+
emg: np.array,
|
|
217
|
+
sampling_rate: int | float,
|
|
218
|
+
epoch_length: int | float,
|
|
219
|
+
) -> np.array:
|
|
220
|
+
"""Stack EEG spectrogram and EMG power into an image
|
|
221
|
+
|
|
222
|
+
This assumes that each epoch contains an integer number of samples and
|
|
223
|
+
each recording contains an integer number of epochs. Note that a log
|
|
224
|
+
transformation is applied to the spectrogram.
|
|
225
|
+
|
|
226
|
+
:param eeg: EEG signal
|
|
227
|
+
:param emg: EMG signal
|
|
228
|
+
:param sampling_rate: sampling rate, in Hz
|
|
229
|
+
:param epoch_length: epoch length, in seconds
|
|
230
|
+
:return: combined EEG + EMG image for a recording
|
|
231
|
+
"""
|
|
232
|
+
spec, f = create_spectrogram(eeg, sampling_rate, epoch_length)
|
|
233
|
+
f_lower_idx = sum(f < DOWNSAMPLING_START_FREQ)
|
|
234
|
+
f_upper_idx = sum(f < UPPER_FREQ)
|
|
235
|
+
|
|
236
|
+
modified_spectrogram = np.log(
|
|
237
|
+
spec[
|
|
238
|
+
np.concatenate(
|
|
239
|
+
[np.arange(0, f_lower_idx), np.arange(f_lower_idx, f_upper_idx, 2)]
|
|
240
|
+
),
|
|
241
|
+
:,
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
emg_log_rms = get_emg_power(emg, sampling_rate, epoch_length)
|
|
246
|
+
output = np.concatenate(
|
|
247
|
+
[modified_spectrogram, np.tile(emg_log_rms, (EMG_COPIES, 1))]
|
|
248
|
+
)
|
|
249
|
+
return output
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_mixture_values(
|
|
253
|
+
img: np.array, labels: np.array, brain_state_set: BrainStateSet
|
|
254
|
+
) -> (np.array, np.array):
|
|
255
|
+
"""Compute weighted feature means and SDs for mixture z-scoring
|
|
256
|
+
|
|
257
|
+
The outputs of this function can be used to standardize features
|
|
258
|
+
extracted from all recordings from one subject under the same
|
|
259
|
+
recording conditions. Note that labels must be in "class" format
|
|
260
|
+
(i.e., integers between 0 and the number of scored states).
|
|
261
|
+
|
|
262
|
+
:param img: combined EEG + EMG image - see create_eeg_emg_image()
|
|
263
|
+
:param labels: brain state labels, in "class" format
|
|
264
|
+
:param brain_state_set: set of brain state options
|
|
265
|
+
:return: mixture means, mixture standard deviations
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
means = list()
|
|
269
|
+
variances = list()
|
|
270
|
+
mixture_weights = brain_state_set.mixture_weights
|
|
271
|
+
|
|
272
|
+
# get feature means, variances by class
|
|
273
|
+
for i in range(brain_state_set.n_classes):
|
|
274
|
+
means.append(np.mean(img[:, labels == i], axis=1))
|
|
275
|
+
variances.append(np.var(img[:, labels == i], axis=1))
|
|
276
|
+
means = np.array(means)
|
|
277
|
+
variances = np.array(variances)
|
|
278
|
+
|
|
279
|
+
# mixture means are just weighted averages across classes
|
|
280
|
+
mixture_means = means.T @ mixture_weights
|
|
281
|
+
# mixture variance is given by the law of total variance
|
|
282
|
+
mixture_sds = np.sqrt(
|
|
283
|
+
variances.T @ mixture_weights
|
|
284
|
+
+ (
|
|
285
|
+
(mixture_means - np.tile(mixture_means, (brain_state_set.n_classes, 1)))
|
|
286
|
+
** 2
|
|
287
|
+
).T
|
|
288
|
+
@ mixture_weights
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return mixture_means, mixture_sds
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def mixture_z_score_img(
|
|
295
|
+
img: np.array,
|
|
296
|
+
brain_state_set: BrainStateSet,
|
|
297
|
+
labels: np.array = None,
|
|
298
|
+
mixture_means: np.array = None,
|
|
299
|
+
mixture_sds: np.array = None,
|
|
300
|
+
) -> np.array:
|
|
301
|
+
"""Perform mixture z-scoring on a combined EEG+EMG image
|
|
302
|
+
|
|
303
|
+
If brain state labels are provided, they will be used to calculate
|
|
304
|
+
mixture means and SDs. Otherwise, you must provide those inputs.
|
|
305
|
+
Note that pixel values in the output are in the 0-1 range and will
|
|
306
|
+
clip z-scores beyond ABS_MAX_Z_SCORE.
|
|
307
|
+
|
|
308
|
+
:param img: combined EEG + EMG image - see create_eeg_emg_image()
|
|
309
|
+
:param brain_state_set: set of brain state options
|
|
310
|
+
:param labels: labels, in "class" format
|
|
311
|
+
:param mixture_means: mixture means
|
|
312
|
+
:param mixture_sds: mixture standard deviations
|
|
313
|
+
:return:
|
|
314
|
+
"""
|
|
315
|
+
if labels is None and (mixture_means is None or mixture_sds is None):
|
|
316
|
+
raise Exception("must provide either labels or mixture means+SDs")
|
|
317
|
+
if labels is not None and ((mixture_means is not None) ^ (mixture_sds is not None)):
|
|
318
|
+
warnings.warn("labels were given, mixture means / SDs will be ignored")
|
|
319
|
+
|
|
320
|
+
if labels is not None:
|
|
321
|
+
mixture_means, mixture_sds = get_mixture_values(
|
|
322
|
+
img=img, labels=labels, brain_state_set=brain_state_set
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
img = ((img.T - mixture_means) / mixture_sds).T
|
|
326
|
+
img = (img + ABS_MAX_Z_SCORE) / (2 * ABS_MAX_Z_SCORE)
|
|
327
|
+
img = np.clip(img, 0, 1)
|
|
328
|
+
|
|
329
|
+
return img
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def format_img(img: np.array, epochs_per_img: int, add_padding: bool) -> np.array:
|
|
333
|
+
"""Adjust the format of an EEG+EMG image
|
|
334
|
+
|
|
335
|
+
This function converts the values in a combined EEG+EMG image to uint8.
|
|
336
|
+
This is a convenient format both for storing individual images as files,
|
|
337
|
+
and for using the images as input to a classifier.
|
|
338
|
+
This function also optionally adds new epochs to the beginning/end of the
|
|
339
|
+
recording's image so that an image can be created for every epoch. For
|
|
340
|
+
real-time scoring, padding should not be used.
|
|
341
|
+
|
|
342
|
+
:param img: combined EEG + EMG image
|
|
343
|
+
:param epochs_per_img: number of epochs in each individual image
|
|
344
|
+
:param add_padding: whether to pad each side by (epochs_per_img - 1) / 2
|
|
345
|
+
:return: formatted EEG + EMG image
|
|
346
|
+
"""
|
|
347
|
+
# pad beginning and end
|
|
348
|
+
if add_padding:
|
|
349
|
+
pad_width = round((epochs_per_img - 1) / 2)
|
|
350
|
+
img = np.concatenate(
|
|
351
|
+
[
|
|
352
|
+
np.tile(img[:, 0], (pad_width, 1)).T,
|
|
353
|
+
img,
|
|
354
|
+
np.tile(img[:, -1], (pad_width, 1)).T,
|
|
355
|
+
],
|
|
356
|
+
axis=1,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# use 8-bit values
|
|
360
|
+
img = np.clip(img * 255, 0, 255)
|
|
361
|
+
img = img.astype(np.uint8)
|
|
362
|
+
|
|
363
|
+
return img
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def create_training_images(
|
|
367
|
+
recordings: list[Recording],
|
|
368
|
+
output_path: str,
|
|
369
|
+
epoch_length: int | float,
|
|
370
|
+
epochs_per_img: int,
|
|
371
|
+
brain_state_set: BrainStateSet,
|
|
372
|
+
model_type: str,
|
|
373
|
+
) -> list[int]:
|
|
374
|
+
"""Create training dataset
|
|
375
|
+
|
|
376
|
+
By default, the current epoch is located in the central column
|
|
377
|
+
of pixels in each image. For real-time scoring applications,
|
|
378
|
+
the current epoch is at the right edge of each image.
|
|
379
|
+
|
|
380
|
+
:param recordings: list of recordings in the training set
|
|
381
|
+
:param output_path: where to store training images
|
|
382
|
+
:param epoch_length: epoch length, in seconds
|
|
383
|
+
:param epochs_per_img: # number of epochs shown in each image
|
|
384
|
+
:param brain_state_set: set of brain state options
|
|
385
|
+
:param model_type: default or real-time
|
|
386
|
+
:return: list of the names of any recordings that could not
|
|
387
|
+
be used to create training images.
|
|
388
|
+
"""
|
|
389
|
+
# recordings that had to be skipped
|
|
390
|
+
failed_recordings = list()
|
|
391
|
+
# image filenames for valid epochs
|
|
392
|
+
filenames = list()
|
|
393
|
+
# all valid labels from all valid recordings
|
|
394
|
+
all_labels = list()
|
|
395
|
+
# try to load each recording and create training images
|
|
396
|
+
for i in trange(len(recordings)):
|
|
397
|
+
recording = recordings[i]
|
|
398
|
+
try:
|
|
399
|
+
eeg, emg = load_recording(recording.recording_file)
|
|
400
|
+
sampling_rate = recording.sampling_rate
|
|
401
|
+
eeg, emg, sampling_rate = resample_and_standardize(
|
|
402
|
+
eeg=eeg,
|
|
403
|
+
emg=emg,
|
|
404
|
+
sampling_rate=sampling_rate,
|
|
405
|
+
epoch_length=epoch_length,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
labels = load_labels(recording.label_file)
|
|
409
|
+
labels = brain_state_set.convert_digit_to_class(labels)
|
|
410
|
+
img = create_eeg_emg_image(eeg, emg, sampling_rate, epoch_length)
|
|
411
|
+
img = mixture_z_score_img(
|
|
412
|
+
img=img, brain_state_set=brain_state_set, labels=labels
|
|
413
|
+
)
|
|
414
|
+
img = format_img(img=img, epochs_per_img=epochs_per_img, add_padding=True)
|
|
415
|
+
|
|
416
|
+
# the model type determines which epochs are used in each image
|
|
417
|
+
if model_type == DEFAULT_MODEL_TYPE:
|
|
418
|
+
# here, j is the index of the current epoch in 'labels'
|
|
419
|
+
# and the index of the leftmost epoch in 'img'
|
|
420
|
+
for j in range(img.shape[1] - (epochs_per_img - 1)):
|
|
421
|
+
if labels[j] is None:
|
|
422
|
+
continue
|
|
423
|
+
im = img[:, j : (j + epochs_per_img)]
|
|
424
|
+
filename = f"recording_{recording.name}_{j}_{labels[j]}.png"
|
|
425
|
+
filenames.append(filename)
|
|
426
|
+
all_labels.append(labels[j])
|
|
427
|
+
Image.fromarray(im).save(os.path.join(output_path, filename))
|
|
428
|
+
else:
|
|
429
|
+
# here, j is the index of the current epoch in 'labels'
|
|
430
|
+
# but we throw away a few epochs at the start since they
|
|
431
|
+
# would require even more padding on the left side.
|
|
432
|
+
one_side_padding = round((epochs_per_img - 1) / 2)
|
|
433
|
+
for j in range(one_side_padding, len(labels)):
|
|
434
|
+
if labels[j] is None:
|
|
435
|
+
continue
|
|
436
|
+
im = img[:, (j - one_side_padding) : j + one_side_padding + 1]
|
|
437
|
+
filename = f"recording_{recording.name}_{j}_{labels[j]}.png"
|
|
438
|
+
filenames.append(filename)
|
|
439
|
+
all_labels.append(labels[j])
|
|
440
|
+
Image.fromarray(im).save(os.path.join(output_path, filename))
|
|
441
|
+
|
|
442
|
+
except Exception as e:
|
|
443
|
+
print(e)
|
|
444
|
+
failed_recordings.append(recording.name)
|
|
445
|
+
|
|
446
|
+
# annotation file containing info on all images
|
|
447
|
+
pd.DataFrame({FILENAME_COL: filenames, LABEL_COL: all_labels}).to_csv(
|
|
448
|
+
os.path.join(output_path, ANNOTATIONS_FILENAME),
|
|
449
|
+
index=False,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
return failed_recordings
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass
|
|
456
|
+
class Bout:
|
|
457
|
+
"""Stores information about a brain state bout"""
|
|
458
|
+
|
|
459
|
+
length: int # length, in number of epochs
|
|
460
|
+
start_index: int # index where bout starts
|
|
461
|
+
end_index: int # index where bout ends
|
|
462
|
+
surrounding_state: int # brain state on both sides of the bout
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def find_last_adjacent_bout(sorted_bouts: list[Bout], bout_index: int) -> int:
|
|
466
|
+
"""Find index of last consecutive same-length bout
|
|
467
|
+
|
|
468
|
+
When running the post-processing step that enforces a minimum duration
|
|
469
|
+
for brain state bouts, there is a special case when bouts below the
|
|
470
|
+
duration threshold occur consecutively. This function performs a
|
|
471
|
+
recursive search for the index of a bout at the end of such a sequence.
|
|
472
|
+
When initially called, bout_index will always be 0. If, for example, the
|
|
473
|
+
first three bouts in the list are consecutive, the function will return 2.
|
|
474
|
+
|
|
475
|
+
:param sorted_bouts: list of brain state bouts, sorted by start time
|
|
476
|
+
:param bout_index: index of the bout in question
|
|
477
|
+
:return: index of the last consecutive same-length bout
|
|
478
|
+
"""
|
|
479
|
+
# if we're at the end of the bout list, stop
|
|
480
|
+
if bout_index == len(sorted_bouts) - 1:
|
|
481
|
+
return bout_index
|
|
482
|
+
|
|
483
|
+
# if there is an adjacent bout
|
|
484
|
+
if sorted_bouts[bout_index].end_index == sorted_bouts[bout_index + 1].start_index:
|
|
485
|
+
# look for more adjacent bouts using that one as a starting point
|
|
486
|
+
return find_last_adjacent_bout(sorted_bouts, bout_index + 1)
|
|
487
|
+
else:
|
|
488
|
+
return bout_index
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def enforce_min_bout_length(
|
|
492
|
+
labels: np.array, epoch_length: int | float, min_bout_length: int | float
|
|
493
|
+
) -> np.array:
|
|
494
|
+
"""Ensure brain state bouts meet the min length requirement
|
|
495
|
+
|
|
496
|
+
As a post-processing step for sleep scoring, we can require that any
|
|
497
|
+
bout (continuous period) of a brain state have a minimum duration.
|
|
498
|
+
This function sets any bout shorter than the minimum duration to the
|
|
499
|
+
surrounding brain state (if the states on the left and right sides
|
|
500
|
+
are the same). In the case where there are consecutive short bouts,
|
|
501
|
+
it either creates a transition at the midpoint or removes all short
|
|
502
|
+
bouts, depending on whether the number is even or odd. For example:
|
|
503
|
+
...AAABABAAA... -> ...AAAAAAAAA...
|
|
504
|
+
...AAABABABBB... -> ...AAAAABBBBB...
|
|
505
|
+
|
|
506
|
+
:param labels: brain state labels (digits in the 0-9 range)
|
|
507
|
+
:param epoch_length: epoch length, in seconds
|
|
508
|
+
:param min_bout_length: minimum bout length, in seconds
|
|
509
|
+
:return: updated brain state labels
|
|
510
|
+
"""
|
|
511
|
+
# if recording is very short, don't change anything
|
|
512
|
+
if labels.size < 3:
|
|
513
|
+
return labels
|
|
514
|
+
|
|
515
|
+
if epoch_length == min_bout_length:
|
|
516
|
+
return labels
|
|
517
|
+
|
|
518
|
+
# get minimum number of epochs in a bout
|
|
519
|
+
min_epochs = int(np.ceil(min_bout_length / epoch_length))
|
|
520
|
+
# get set of states in the labels
|
|
521
|
+
brain_states = set(labels.tolist())
|
|
522
|
+
|
|
523
|
+
while True: # so true
|
|
524
|
+
# convert labels to a string for regex search
|
|
525
|
+
# There is probably a regex that can find all patterns like ab+a
|
|
526
|
+
# without consuming each "a" but I haven't found it :(
|
|
527
|
+
label_string = "".join(labels.astype(str))
|
|
528
|
+
|
|
529
|
+
bouts = list()
|
|
530
|
+
|
|
531
|
+
for state in brain_states:
|
|
532
|
+
for other_state in brain_states:
|
|
533
|
+
if state == other_state:
|
|
534
|
+
continue
|
|
535
|
+
# get start and end indices of each bout
|
|
536
|
+
expression = (
|
|
537
|
+
f"(?<={other_state}){state}{{1,{min_epochs - 1}}}(?={other_state})"
|
|
538
|
+
)
|
|
539
|
+
matches = re.finditer(expression, label_string)
|
|
540
|
+
spans = [match.span() for match in matches]
|
|
541
|
+
|
|
542
|
+
# if some bouts were found
|
|
543
|
+
for span in spans:
|
|
544
|
+
bouts.append(
|
|
545
|
+
Bout(
|
|
546
|
+
length=span[1] - span[0],
|
|
547
|
+
start_index=span[0],
|
|
548
|
+
end_index=span[1],
|
|
549
|
+
surrounding_state=other_state,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if len(bouts) == 0:
|
|
554
|
+
break
|
|
555
|
+
|
|
556
|
+
# only keep the shortest bouts
|
|
557
|
+
min_length_in_list = np.min([bout.length for bout in bouts])
|
|
558
|
+
bouts = [i for i in bouts if i.length == min_length_in_list]
|
|
559
|
+
# sort by start index
|
|
560
|
+
sorted_bouts = sorted(bouts, key=attrgetter("start_index"))
|
|
561
|
+
|
|
562
|
+
while len(sorted_bouts) > 0:
|
|
563
|
+
# get row index of latest adjacent bout (of same length)
|
|
564
|
+
last_adjacent_bout_index = find_last_adjacent_bout(sorted_bouts, 0)
|
|
565
|
+
# if there's an even number of adjacent bouts
|
|
566
|
+
if (last_adjacent_bout_index + 1) % 2 == 0:
|
|
567
|
+
midpoint = sorted_bouts[
|
|
568
|
+
round((last_adjacent_bout_index + 1) / 2)
|
|
569
|
+
].start_index
|
|
570
|
+
labels[sorted_bouts[0].start_index : midpoint] = sorted_bouts[
|
|
571
|
+
0
|
|
572
|
+
].surrounding_state
|
|
573
|
+
labels[midpoint : sorted_bouts[last_adjacent_bout_index].end_index] = (
|
|
574
|
+
sorted_bouts[last_adjacent_bout_index].surrounding_state
|
|
575
|
+
)
|
|
576
|
+
else:
|
|
577
|
+
labels[
|
|
578
|
+
sorted_bouts[0].start_index : sorted_bouts[
|
|
579
|
+
last_adjacent_bout_index
|
|
580
|
+
].end_index
|
|
581
|
+
] = sorted_bouts[0].surrounding_state
|
|
582
|
+
|
|
583
|
+
# delete the bouts we just fixed
|
|
584
|
+
if last_adjacent_bout_index == len(sorted_bouts) - 1:
|
|
585
|
+
sorted_bouts = []
|
|
586
|
+
else:
|
|
587
|
+
sorted_bouts = sorted_bouts[(last_adjacent_bout_index + 1) :]
|
|
588
|
+
|
|
589
|
+
return labels
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: accusleepy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python implementation of AccuSleep
|
|
5
|
+
License: GPL-3.0-only
|
|
6
|
+
Author: Zeke Barger
|
|
7
|
+
Author-email: zekebarger@gmail.com
|
|
8
|
+
Requires-Python: >=3.10,<3.14
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: fastparquet (>=2024.11.0,<2025.0.0)
|
|
16
|
+
Requires-Dist: joblib (>=1.4.2,<2.0.0)
|
|
17
|
+
Requires-Dist: matplotlib (>=3.10.1,<4.0.0)
|
|
18
|
+
Requires-Dist: numpy (>=2.2.4,<3.0.0)
|
|
19
|
+
Requires-Dist: pandas (>=2.2.3,<3.0.0)
|
|
20
|
+
Requires-Dist: pillow (>=11.1.0,<12.0.0)
|
|
21
|
+
Requires-Dist: pre-commit (>=4.2.0,<5.0.0)
|
|
22
|
+
Requires-Dist: pyside6 (>=6.8.3,<7.0.0)
|
|
23
|
+
Requires-Dist: scipy (>=1.15.2,<2.0.0)
|
|
24
|
+
Requires-Dist: torch (>=2.6.0,<3.0.0)
|
|
25
|
+
Requires-Dist: torchvision (>=0.21.0,<0.22.0)
|
|
26
|
+
Requires-Dist: tqdm (>=4.67.1,<5.0.0)
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# AccuSleePy
|
|
30
|
+
|
|
31
|
+
## Description
|
|
32
|
+
|
|
33
|
+
AccuSleePy is a python implementation of AccuSleep--a set of graphical user interfaces for scoring rodent
|
|
34
|
+
sleep using EEG and EMG recordings. If you use AccuSleep in your research, please cite our
|
|
35
|
+
[publication](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0224642):
|
|
36
|
+
|
|
37
|
+
Barger, Z., Frye, C. G., Liu, D., Dan, Y., & Bouchard, K. E. (2019). Robust, automated sleep scoring by a compact neural network with distributional shift correction. *PLOS ONE, 14*(12), 1–18.
|
|
38
|
+
|
|
39
|
+
The data used for training and testing AccuSleep are available at https://osf.io/py5eb/
|
|
40
|
+
|
|
41
|
+
Please contact zekebarger (at) gmail (dot) com with any questions or comments about the software.
|
|
42
|
+
|
|
43
|
+
## Installation instructions
|
|
44
|
+
|
|
45
|
+
WIP
|
|
46
|
+
|
|
47
|
+
## Tips & Troubleshooting
|
|
48
|
+
|
|
49
|
+
WIP
|
|
50
|
+
|
|
51
|
+
## Acknowledgements
|
|
52
|
+
|
|
53
|
+
We would like to thank [Franz Weber](https://www.med.upenn.edu/weberlab/) for creating an
|
|
54
|
+
early version of the manual labeling interface.
|
|
55
|
+
Jim Bohnslav's [deepethogram](https://github.com/jbohnslav/deepethogram) served as an
|
|
56
|
+
incredibly useful reference when reimplementing this project in python.
|
|
57
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
accusleepy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
accusleepy/__main__.py,sha256=dKzl2N2Hg9lD264CWYNxThRyDKzWwyMwHRXmJxOmMis,104
|
|
3
|
+
accusleepy/brain_state_set.py,sha256=fRkrArHLIbEKimub804yt_mUXoyfsjJEfiJnTjeCMkY,3233
|
|
4
|
+
accusleepy/classification.py,sha256=xrmPyMHlzYh0QfNCID1PRIYEIyNkWduOi7g1Bdb6xfg,8573
|
|
5
|
+
accusleepy/config.json,sha256=GZV0d1E44NEj-P7X5GKjbxbjjYKWUtXAm29a3vGABVA,430
|
|
6
|
+
accusleepy/constants.py,sha256=ceRcWmhec5sATiAsIynf3XFKl0H2IsT_JDfeg7xYbqg,1189
|
|
7
|
+
accusleepy/fileio.py,sha256=ojuzDkcJkXok9dny75g8LDieKeMT7P2SFVLyJek6Nd8,6011
|
|
8
|
+
accusleepy/gui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
accusleepy/gui/icons/brightness_down.png,sha256=PLT1fb83RHIhSRuU7MMMx0G7oJAY7o9wUcnqM8veZfM,12432
|
|
10
|
+
accusleepy/gui/icons/brightness_up.png,sha256=64GnUqgPvN5xZ6Um3wOzwqvUmdAWYZT6eFmWpBsHyks,12989
|
|
11
|
+
accusleepy/gui/icons/double_down_arrow.png,sha256=fGiPIP7_RJ3UAonNhORFVX0emXEmtzRlHI3Tfjai064,4964
|
|
12
|
+
accusleepy/gui/icons/double_up_arrow.png,sha256=n7QEo0bZZDve4thwTCKghPKVjTNbQMgyQNsn46iqXbI,5435
|
|
13
|
+
accusleepy/gui/icons/down_arrow.png,sha256=XwS_Gq2j6PoNHRaeaAGoh5kcXJNXWAHWWbJbUsvrRPU,3075
|
|
14
|
+
accusleepy/gui/icons/home.png,sha256=yd3nmHlD9w2a2j3cBd-w_Cuidr-J0apryRoWJoPb66w,5662
|
|
15
|
+
accusleepy/gui/icons/question.png,sha256=IJcIRgQOC9KlzA4vtA5Qu-DQ1-SclhVLeovIsEfl3TU,17105
|
|
16
|
+
accusleepy/gui/icons/save.png,sha256=J3EA8iU1BqLYRSsrq_OdoZlqrv2yfL7oV54DklTy_DI,13555
|
|
17
|
+
accusleepy/gui/icons/up_arrow.png,sha256=V9yF9t1WgjPaUu-mF1YGe_DfaRHg2dUpR_sUVVcvVvY,3329
|
|
18
|
+
accusleepy/gui/icons/zoom_in.png,sha256=MFWnKZp7Rvh4bLPq4Cqo4sB_jQYedUUtT8-ZO8tNYyc,13589
|
|
19
|
+
accusleepy/gui/icons/zoom_out.png,sha256=IB8Jecb3i0U4qjWRR46ridjLpvLCSe7PozBaLqQqYSw,13055
|
|
20
|
+
accusleepy/gui/main.py,sha256=clnk3Tuzu1W6IrXjoRU1IaFNkhZZfohNriSZiE2mj6I,53008
|
|
21
|
+
accusleepy/gui/manual_scoring.py,sha256=Ce-X4A7pm3Hb0PtiqebF59rpSEMcHhWWhoO-L7rYtTE,40677
|
|
22
|
+
accusleepy/gui/mplwidget.py,sha256=f9O3u_96whQGUwpi3o_QGc7yjiETX5vE0oj3ePXTJWE,12279
|
|
23
|
+
accusleepy/gui/primary_window.py,sha256=5aXAkX4tI6MkA_zNexlrNzaLFPaIUAtxUdcu8QAJWYI,97441
|
|
24
|
+
accusleepy/gui/primary_window.ui,sha256=TswlioQasBB1lMfP8K1BYnh-xTk5odsmjCctP54w53o,135230
|
|
25
|
+
accusleepy/gui/resources.qrc,sha256=ByNEmJqr0YbKBqoZGvONZtjyNYr4ST4enO6TEdYSqWg,802
|
|
26
|
+
accusleepy/gui/resources_rc.py,sha256=Z2e34h30U4snJjnYdZVV9B6yjATKxxfvgTRt5uXtQdo,329727
|
|
27
|
+
accusleepy/gui/text/config_guide.txt,sha256=4Tbo1LctcJ_gdcr4Z3jBoLog5CUr8Pccqrgdi3uYU6g,903
|
|
28
|
+
accusleepy/gui/text/main_guide.txt,sha256=bIDL8SQKK88MhVqmgKYQWygR4oSfS-CsKNuUUOi2hzU,7684
|
|
29
|
+
accusleepy/gui/text/manual_scoring_guide.txt,sha256=onBnUZJyX18oN1CgjD2HSnlEQHsUscHpOYf11kTKZ4U,1460
|
|
30
|
+
accusleepy/gui/viewer_window.py,sha256=5PkbuYMXUegH1CExCoqSGDZ9GeJqCCUz0-3WWkM8Vfc,24049
|
|
31
|
+
accusleepy/gui/viewer_window.ui,sha256=D1LwUFR-kZ_GWGZFFtXvGJdFWghLrOWZTblQeLQt9kI,30525
|
|
32
|
+
accusleepy/models.py,sha256=Muapsw088AUHqRIbW97Rkbv0oiwCtQvO9tEoBCC-MYg,1476
|
|
33
|
+
accusleepy/multitaper.py,sha256=V6MJDk0OSWhg2MFhrnt9dvYrHiNsk2T7IxAA7paZVyE,25549
|
|
34
|
+
accusleepy/signal_processing.py,sha256=-aXnywfp1LBsk3DcbIMmZlgv3f8j6sZ6js0bizZId0o,21718
|
|
35
|
+
accusleepy-0.1.0.dist-info/METADATA,sha256=2m9Cniw45DzhegusOsXutTofuxtptRDi7I3I24s3C10,2158
|
|
36
|
+
accusleepy-0.1.0.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
|
|
37
|
+
accusleepy-0.1.0.dist-info/RECORD,,
|