Skip to content

Commit 7c7871c

Browse files
authored
Merge pull request #425 from Mentalab-hub/asr-testing
ASR processor + example script
2 parents bbe1cf5 + 7cabfc3 commit 7c7871c

7 files changed

Lines changed: 880 additions & 28 deletions

File tree

examples/clean_data_from_exg.py

Lines changed: 472 additions & 0 deletions
Large diffs are not rendered by default.

src/explorepy/asr_processor.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import logging
2+
import math
3+
import time
4+
from enum import (
5+
Enum,
6+
auto
7+
)
8+
9+
import numpy as np
10+
from eegprep import (
11+
clean_flatlines,
12+
clean_windows
13+
)
14+
from eegprep.utils import round_mat
15+
from eegprep.utils.asr import (
16+
asr_calibrate,
17+
asr_process
18+
)
19+
20+
from explorepy.filters import ExGFilter
21+
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
class State(Enum):
27+
STABLE = auto()
28+
CLEANING = auto()
29+
CALIBRATION_ERROR = auto()
30+
31+
32+
def clean_calib_data(clean_data, sampling_rate):
33+
EEG = {'data': clean_data, 'srate': sampling_rate, 'xmin': 0}
34+
try:
35+
cleaned_windows = clean_windows(EEG) # throws index error
36+
logger.info(f"cleaned window shape: {cleaned_windows[0]['data'].shape} and "
37+
f"original data shape: {clean_data.shape}")
38+
39+
cleaned = clean_flatlines(cleaned_windows[0])
40+
if cleaned['data'].shape[0] != clean_data.shape[0]:
41+
logger.info(f"clean_data.shape: f{clean_data.shape} and cleaned['data'].shape: {cleaned['data'].shape}")
42+
raise IndexError
43+
return cleaned['data'], State.STABLE
44+
except IndexError:
45+
logger.info("Calibration error")
46+
return None, State.CALIBRATION_ERROR
47+
48+
49+
def asr_pipeline(data_array, sampling_rate, n_chan, state, step_size=None, window_len=None, max_dims=0.66):
50+
"""This code is mostly taken from the eegprep implementation of clean_asr and adapted to work with a previously
51+
calculated state."""
52+
data = np.asarray(data_array, dtype=np.float64)
53+
srate = float(sampling_rate)
54+
nbchan = int(n_chan)
55+
C, S = data.shape
56+
57+
if window_len is None:
58+
window_len = max(0.5, 1.5 * nbchan / srate)
59+
60+
if step_size is None:
61+
step_size = int(math.floor(srate * window_len / 2)) # Samples
62+
63+
N_extrap = int(round_mat(window_len / 2 * srate))
64+
if N_extrap > 0:
65+
extrap_len = min(N_extrap, S - 1 if S > 1 else 0)
66+
if extrap_len > 0:
67+
extrap_indices = np.arange(S - 2, S - extrap_len - 2, -1)
68+
extrap_part = 2 * data[:, [-1]] - data[:, extrap_indices]
69+
sig = np.concatenate((data, extrap_part), axis=1)
70+
else:
71+
sig = data
72+
else:
73+
sig = data
74+
75+
lookahead_sec = window_len / 2.0
76+
outdata, _ = asr_process(
77+
sig,
78+
srate,
79+
state,
80+
window_len=window_len,
81+
lookahead=lookahead_sec,
82+
step_size=step_size,
83+
max_dims=max_dims
84+
)
85+
86+
outdata = outdata[:, :S]
87+
88+
return outdata
89+
90+
91+
class AsrProcessor:
92+
_min_calibration_length: float = 10. # in s
93+
_default_calibration_length: float = 30. # in s
94+
_max_calibration_length: float = 120. # in s
95+
96+
_min_cutoff: float = 3.0
97+
_default_cutoff: float = 5.0
98+
_max_cutoff: float = 20.0
99+
100+
_min_clean_timer: float = 0.01 # in s
101+
_default_clean_timer: float = 1.0 # in s
102+
_max_clean_timer: float = 30.0 # in s
103+
104+
def __init__(self, stream_proc, in_topic):
105+
self.stream_processor = stream_proc
106+
self.in_topic = in_topic
107+
self.is_initialized = False
108+
self.cleaned_data_available = False
109+
self.cleaned_data = None
110+
self.cleaned_data_ts = None
111+
info_keys = self.stream_processor.device_info.keys()
112+
if "sampling_rate" not in info_keys or "firmware_version" not in info_keys:
113+
logger.error("Sampling rate or firmware version not available from stream processor, "
114+
"cannot instantiate AsrProcessor!")
115+
return
116+
self.sr = self.stream_processor.device_info["sampling_rate"]
117+
fw = self.stream_processor.device_info["firmware_version"]
118+
self.ch_count = 8 if fw[0] == '7' else 16 if fw[0] == '8' else 32
119+
120+
self.calibration_data_input = np.empty(shape=(self.ch_count, 0))
121+
self.is_calibrating = False
122+
self.calibration_data_available = False
123+
self.is_cleaning = False
124+
125+
self.asr_packet_count = 0
126+
self.calib_started_at: float = -1.0
127+
self.calibration_length: float = self._default_calibration_length # in s
128+
129+
self.last_clean_at: float = -1.0 # the last time the to_clean buffer was cleaned
130+
self.last_cleaned_timestamp = 0.0
131+
self._refresh_window: float = self._default_clean_timer # how long to wait between running asr, in s
132+
133+
self._cutoff = self._default_cutoff
134+
self._state = None
135+
136+
self.to_clean_buffer_length = 5. # in s
137+
self.instantiate_buffers()
138+
self.is_initialized = True
139+
self.lifecycle_state = State.STABLE
140+
self.filter = None
141+
142+
@property
143+
def cutoff(self):
144+
return self._cutoff
145+
146+
@cutoff.setter
147+
def cutoff(self, new_cutoff):
148+
if self._min_cutoff <= new_cutoff <= self._max_cutoff:
149+
self._cutoff = new_cutoff
150+
self.set_state_from_calibration_data(self.calibration_data_input)
151+
else:
152+
raise ValueError(f"Passed cutoff for ASR of {new_cutoff} is not within accepted range of "
153+
f"[{self._min_cutoff},{self._max_cutoff}]")
154+
155+
@property
156+
def refresh_window(self):
157+
return self._refresh_window
158+
159+
@refresh_window.setter
160+
def refresh_window(self, new_window):
161+
self._refresh_window = new_window
162+
163+
def instantiate_buffers(self):
164+
self.to_clean = np.zeros(shape=(self.ch_count, int(self.sr * self.to_clean_buffer_length)))
165+
self.to_clean_ts = np.zeros(shape=(1, int(self.sr * self.to_clean_buffer_length)))
166+
167+
def update_device_data(self, ch_count: int, sr: float):
168+
self.ch_count = ch_count
169+
self.sr = sr
170+
171+
self.calibration_data_input = np.empty(shape=(self.ch_count, 0))
172+
self.instantiate_buffers()
173+
174+
def clear_calibration_data(self):
175+
self.calibration_data_input = np.empty(shape=(self.ch_count, 0))
176+
177+
def clear_data_buffer(self):
178+
self.instantiate_buffers()
179+
180+
def on_calib_data_received(self, packet):
181+
if (
182+
self.calib_started_at <= 0.0
183+
or not self._min_calibration_length <= self.calibration_length <= self._max_calibration_length
184+
):
185+
raise ValueError(
186+
"Error writing calibration packet, timer has not been set correctly or calibration length is invalid!"
187+
)
188+
if (time.time() - self.calib_started_at) > self.calibration_length:
189+
self.calibration_data_available = True
190+
self.stop_calibration()
191+
return
192+
self.calibration_data_input = np.append(
193+
self.calibration_data_input,
194+
self.filter.apply(packet, in_place=False).get_data()[1],
195+
axis=1,
196+
)
197+
198+
def on_unclean_data_received(self, packet):
199+
if self.last_clean_at <= 0.0:
200+
self.last_clean_at = time.time()
201+
if not self.calibration_data_available:
202+
logger.warning("Attempting to clean data with no calibration available - returning...")
203+
new_data = np.array(packet.get_data()[1])
204+
new_ts = np.array(packet.get_data()[0])
205+
self.to_clean[:, :new_data.shape[1]] = new_data
206+
self.to_clean = np.roll(self.to_clean, -new_data.shape[1], axis=1)
207+
self.to_clean_ts[0, :new_ts.shape[0]] = new_ts
208+
self.to_clean_ts = np.roll(self.to_clean_ts, -new_ts.shape[0], axis=1)
209+
210+
if time.time() - self.last_clean_at >= self._refresh_window:
211+
self.clean_data()
212+
self.last_clean_at = -1.0
213+
214+
def clear_cleaned_data(self):
215+
self.cleaned_data_available = False
216+
self.cleaned_data = {}
217+
218+
def clean_data(self):
219+
if self._state is None:
220+
logger.warning("Requested cleaning data with ASR but internal calibration state is None!")
221+
return
222+
if self.to_clean_ts[0][0] <= 1.0:
223+
return
224+
try:
225+
ret = asr_pipeline(self.to_clean, self.sr, self.ch_count, self._state)
226+
self.cleaned_data_available = True
227+
idx = np.searchsorted(self.to_clean_ts[0], self.last_cleaned_timestamp)
228+
self.cleaned_data = ret[:, idx:]
229+
self.cleaned_data_ts = self.to_clean_ts.copy()[0, idx:]
230+
231+
self.last_cleaned_timestamp = self.to_clean_ts[0][-1]
232+
except ValueError:
233+
logger.error("Could not get ASR from input window!")
234+
235+
def start_cleaning(self, clean_timer: float = None):
236+
if clean_timer is None:
237+
self._refresh_window = self._default_clean_timer
238+
elif self._min_clean_timer <= clean_timer <= self._max_clean_timer:
239+
self._refresh_window = clean_timer
240+
else:
241+
logger.error(f"Passed refresh timer for ASR of {clean_timer} is not within accepted range of "
242+
f"[{self._min_clean_timer},{self._max_clean_timer}]")
243+
logger.info(f"Starting cleaning with ASR (refresh every {self._refresh_window}s)...")
244+
self.is_cleaning = True
245+
self.stream_processor.subscribe(self.on_unclean_data_received, topic=self.in_topic)
246+
247+
def stop_cleaning(self):
248+
logger.info("Stopping cleaning with ASR.")
249+
self.is_cleaning = False
250+
self.stream_processor.unsubscribe(self.on_unclean_data_received, topic=self.in_topic)
251+
252+
def start_calibration(self, calib_length: float = -1.0):
253+
self.is_calibrating = True
254+
if self._min_calibration_length <= calib_length <= self._max_calibration_length:
255+
self.calibration_length = calib_length
256+
else:
257+
logger.error(f"Passed refresh timer for ASR of {calib_length} is not within accepted range of "
258+
f"[{self._min_calibration_length},{self._max_calibration_length}]")
259+
logger.info(f"Starting ASR calibration for {self.calibration_length}s...")
260+
self.calib_started_at = time.time()
261+
self.filter = ExGFilter(
262+
cutoff_freq=(1, 45),
263+
filter_type='bandpass',
264+
s_rate=self.sr,
265+
n_chan=self.ch_count,
266+
)
267+
self.stream_processor.subscribe(self.on_calib_data_received, topic=self.in_topic)
268+
269+
def stop_calibration(self):
270+
logger.info("Stopping ASR calibration.")
271+
self.stream_processor.unsubscribe(self.on_calib_data_received, topic=self.in_topic)
272+
self.is_calibrating = False
273+
self.calib_started_at = -1.0
274+
self.calibration_length = self._default_calibration_length
275+
self.set_state_from_calibration_data(self.calibration_data_input)
276+
277+
def set_state_from_calibration_data(self, calib_data):
278+
self.lifecycle_state = State.CLEANING
279+
cleaned, state = clean_calib_data(calib_data, self.sr)
280+
self.lifecycle_state = state
281+
if cleaned is None:
282+
self.calibration_data_available = False
283+
return
284+
self._state = asr_calibrate(cleaned, self.sr, cutoff=self._cutoff)

0 commit comments

Comments
 (0)