Skip to content

Commit 3be693f

Browse files
authored
Merge pull request #21 from PayalLakra/bio_amptool
Added - emg.ipynb and CSV File for emg & ecg
2 parents d7a046c + d8aa273 commit 3be693f

File tree

10 files changed

+731
-313
lines changed

10 files changed

+731
-313
lines changed

Notebooks/ECG.ipynb renamed to Notebooks/ecg.ipynb

Lines changed: 36 additions & 212 deletions
Large diffs are not rendered by default.

Notebooks/emg.ipynb

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

Notebooks/eog.ipynb

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

README.md

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
# Chords - Python
22

3-
Chords Python script is designed to interface with an Arduino-based bioamplifier, read data from it, optionally log this data to CSV or stream it via the Lab Streaming Layer (LSL), and visualize it through a graphical user interface (GUI) with live plotting.
3+
Chords Python script is designed to interface with an Arduino-based bio-potential amplifier, read data from it, optionally log this data to CSV or stream it via the Lab Streaming Layer (LSL), and visualize it through a graphical user interface (GUI) with live plotting.
44

55
> [!NOTE]
66
> Flash Arduino code to your hardware from [Chords Arduino Firmware](https://github.com/upsidedownlabs/Chords-Arduino-Firmware) to use this python tool.
77
88
## Features
99

1010
- **Automatic Arduino Detection:** Automatically detects connected Arduino devices via serial ports.
11-
- **Data Reading:** Read ModularEEG P2 format data packets from the Arduino's serial port.
11+
- **Data Reading:** Read data packets from the Arduino's serial port.
1212
- **CSV Logging:** Optionally logs data to a CSV file.
1313
- **LSL Streaming:** Optionally streams data to an LSL outlet for integration with other software.
1414
- **Verbose Output:** Provides detailed statistics and error reporting, including sampling rate and drift.
1515
- **GUI:** Live plotting of six channels using a PyQt-based GUI.
16+
- **Invert:** Optionally Invert the signal before streaming LSL and logging
1617
- **Timer:** Record data for a set time period in seconds.
1718

1819
## Requirements
@@ -101,61 +102,26 @@ To use the script, run it from the command line with various options:
101102
102103
- `python gui.py`: Enable the real-time data plotting GUI.
103104
104-
#### Script Functions
105-
106-
`init_gui()`: Initializes and displays the GUI with six real-time plots, one for each bio-signal channel.
107-
108-
`update_plots()`: Updates the plot data by pulling new samples from the LSL stream and shifting the existing buffer.
109-
110105
### FORCE BALL GAME
111106
112107
- `python game.py`: Enable a GUI to play game using EEG Signal.
113108
114-
#### Script Functions
115-
116-
`bandpower(data, sf, band, window_sec=None, relative=False)`: Calculates the band power of EEG data in a specified frequency band using the Welch method.
117-
118-
`eeg_data_thread(eeg_queue)`: Continuously retrieves EEG data from an LSL stream and computes power ratios for Player A and Player B.
119-
120-
`reset_game()`: Resets the game state and initializes the ball and player forces.
121-
122-
`update_ball_position(force_player1, force_player2)`: Updates the ball's position based on the net force exerted by both players.
123-
124-
`check_win_condition()`: Determines if either player has won based on the ball's position.
125-
126109
### HEART RATE
127110
128111
- `python heartbeat.ecg.py`:Enable a GUI with real-time ECG and heart rate.
129112
130-
#### Script Functions
131-
132-
`butter_filter(cutoff, fs, order=4, btype='low')`: Designs a Butterworth filter to remove unwanted frequencies from the ECG signal.
133-
134-
`apply_filter(data, b, a)`: Applies the designed Butterworth filter to the ECG data for noise reduction.
135-
136-
`detect_heartbeats(ecg_data, sampling_rate)`: Detects heartbeats in the ECG signal using peak detection.
137-
138-
`run(self)`: Collects ECG data from the LSL stream, applies filtering, and emits the filtered data for real-time plotting.
139-
140-
`update_plot(self, ecg_data)`: Updates the plot with the latest ECG data and detects heartbeats to display on the GUI.
141-
142-
`update_heart_rate(self)`: Calculates and updates the heart rate based on detected R-peaks in the ECG signal.
143-
144113
### EMG ENVELOPE
145114
146-
- `python emgenvelope.py` :Enable a GUI with real-time EMG & its Envelope.
147-
148-
#### Script Functions
149-
150-
`update_plot` : Updates the plot with latest Filtered EMG Data and its Envelope.
115+
- `python emgenvelope.py`: Enable a GUI with real-time EMG & its Envelope.
151116
152117
### EOG
153118
154-
- `python eog.py` :Enable a GUI with real-time EOG.
119+
- `python eog.py`: Enable a GUI with real-time EOG that detects the blinks and mark them with red dot.
120+
121+
### EEG
155122
156-
#### Script Functions
123+
- `python ffteeg.py`: Enable a GUI with real-time EEG data with its FFT.
157124
158-
`update_plot` : Updates the plot with latest Filtered EOG Data.
159125
160126
## Troubleshooting
161127

applications/eog.py

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,47 @@
11
import numpy as np
2-
from scipy.signal import butter, filtfilt
3-
from PyQt5.QtWidgets import QApplication, QVBoxLayout, QMainWindow, QWidget
4-
from pyqtgraph import PlotWidget
2+
from scipy.signal import butter, lfilter, lfilter_zi
3+
from PyQt5.QtWidgets import QApplication, QVBoxLayout, QMainWindow, QWidget, QHBoxLayout
54
import pyqtgraph as pg
65
import pylsl
76
import sys
7+
import time
8+
from collections import deque
89

910
class EOGMonitor(QMainWindow):
10-
def __init__(self):
11+
def __init__(self):
1112
super().__init__()
1213

1314
self.setWindowTitle("Real-Time EOG Monitor - Eye Blink Detection")
1415
self.setGeometry(100, 100, 800, 400)
1516

1617
# Create layout
1718
layout = QVBoxLayout()
19+
central_widget = QWidget()
20+
central_widget.setLayout(layout)
21+
self.setCentralWidget(central_widget)
1822

1923
# Create plot widget for EOG
20-
self.eog_plot = PlotWidget(self)
24+
self.eog_plot = pg.PlotWidget(self)
2125
self.eog_plot.setBackground('w')
2226
self.eog_plot.showGrid(x=True, y=True)
27+
# self.eog_plot.setAutoVisible(y=True) # On Autoscale for y-axis only
28+
self.eog_plot.setMouseEnabled(x=False, y=False) # To Disable zoom functionality
2329
self.eog_plot.setTitle("Filtered EOG Signal (Low Pass: 10 Hz)")
2430

25-
# Add plot to layout
26-
layout.addWidget(self.eog_plot)
31+
# Bottom layout for Blink Detection
32+
self.bottom_layout = QHBoxLayout()
2733

28-
# Central widget
29-
central_widget = QWidget()
30-
central_widget.setLayout(layout)
31-
self.setCentralWidget(central_widget)
34+
# Blink detection plot
35+
self.blink_plot = pg.PlotWidget(self)
36+
self.blink_plot.setBackground('w')
37+
self.blink_plot.showGrid(x=True, y=True)
38+
self.blink_plot.setYRange(0, 1, padding=0)
39+
self.blink_plot.setMouseEnabled(x=False, y=False) # To Disable zoom functionality mark it as true
40+
self.blink_plot.setTitle("Blink Detection")
41+
42+
# Add both plots to the layout
43+
layout.addWidget(self.eog_plot)
44+
layout.addWidget(self.blink_plot)
3245

3346
# Set up LSL stream inlet
3447
streams = pylsl.resolve_stream('name', 'BioAmpDataStream')
@@ -37,33 +50,37 @@ def __init__(self):
3750
sys.exit(0)
3851
self.inlet = pylsl.StreamInlet(streams[0])
3952

40-
# Sampling rate
4153
self.sampling_rate = int(self.inlet.info().nominal_srate())
4254
print(f"Sampling rate: {self.sampling_rate} Hz")
43-
44-
# Data and buffer
55+
4556
self.buffer_size = self.sampling_rate * 5 # 5 seconds buffer for recent data
4657
self.eog_data = np.zeros(self.buffer_size)
4758
self.time_data = np.linspace(0, 5, self.buffer_size)
59+
self.blink_data = np.zeros(self.buffer_size) # Blink data array
4860
self.current_index = 0
4961

5062
# Low-pass filter for EOG (10 Hz)
5163
self.b, self.a = butter(4, 10.0 / (0.5 * self.sampling_rate), btype='low')
64+
self.zi = lfilter_zi(self.b, self.a) # Initialize filter state
5265

53-
# Set fixed axis ranges
5466
self.eog_plot.setXRange(0, 5, padding=0)
55-
if self.sampling_rate == 250:
56-
self.eog_plot.setYRange(-((2**10)/2), ((2**10)/2), padding=0)
57-
elif self.sampling_rate == 500:
58-
self.eog_plot.setYRange(-((2**14)/2), ((2**14)/2), padding=0)
67+
if self.sampling_rate == 250:
68+
self.eog_plot.setYRange(0, 2**10,padding=0) # for R3 & ensuring no extra spaces at end
69+
elif self.sampling_rate == 500:
70+
self.eog_plot.setYRange(0, 2**14,padding=0) # for R4 & ensuring no extra spaces at end
5971

60-
# Plot curve for EOG data
72+
# Plot curves
6173
self.eog_curve = self.eog_plot.plot(self.time_data, self.eog_data, pen=pg.mkPen('b', width=1))
74+
self.blink_curve = self.blink_plot.plot(self.time_data, self.blink_data, pen=pg.mkPen('r', width=2))
75+
76+
# Circular buffer for detected peaks
77+
self.detected_peaks = deque(maxlen=self.sampling_rate * 5) # Store peaks with 5-second window
6278

6379
# Timer for plot update
6480
self.timer = pg.QtCore.QTimer()
6581
self.timer.timeout.connect(self.update_plot)
6682
self.timer.start(15)
83+
self.start_time = time.time()
6784

6885
def update_plot(self):
6986
samples, _ = self.inlet.pull_chunk(timeout=0.0, max_samples=30)
@@ -73,14 +90,78 @@ def update_plot(self):
7390
self.eog_data[self.current_index] = sample[0]
7491
self.current_index = (self.current_index + 1) % self.buffer_size
7592

76-
# Apply only the low-pass filter to the EOG data
77-
filtered_eog = filtfilt(self.b, self.a, self.eog_data)
93+
# Filter only the new data (not the entire buffer)
94+
filtered_eog, self.zi = lfilter(self.b, self.a, self.eog_data, zi=self.zi)
95+
96+
# Update curve with the filtered EOG signal (5-second window)
97+
self.eog_plot.clear() # Clear the previous peaks from the plot
98+
self.eog_curve = self.eog_plot.plot(self.time_data, filtered_eog, pen=pg.mkPen('b', width=1))
99+
100+
if time.time() - self.start_time >= 2:
101+
self.detect_blinks(filtered_eog)
102+
103+
# Clear out old peaks from the circular buffer after 4 seconds(As we want to clear the peaks just after the data overwrite.)
104+
current_time = time.time()
105+
while self.detected_peaks and (current_time - self.detected_peaks[0][1] > 4):
106+
self.detected_peaks.popleft() # Remove old peaks from the buffer
107+
108+
# Update the blink plot based on stored peaks
109+
self.blink_data[:] = 0 # Reset blink data
110+
for index, _ in self.detected_peaks:
111+
if 0 <= index < self.buffer_size:
112+
self.blink_data[index] = 1 # Keep blink data high at detected peaks
113+
114+
# Mark the stored peaks on the EOG plot
115+
peak_indices = [index for index, t in self.detected_peaks]
116+
peak_values = [filtered_eog[i] for i in peak_indices]
117+
self.eog_plot.plot(self.time_data[peak_indices], peak_values, pen=None, symbol='o', symbolPen='r', symbolSize=6)
118+
119+
# Update the blink plot with the current blink data
120+
self.blink_curve.setData(self.time_data, self.blink_data)
121+
122+
def detect_blinks(self, filtered_eog):
123+
mean_signal = np.mean(filtered_eog)
124+
stdev_signal = np.std(filtered_eog)
125+
threshold = mean_signal + (2 * stdev_signal)
126+
127+
# Calculate the start and end indices for the 1-second window
128+
window_size = 1 * self.sampling_rate
129+
start_index = self.current_index - window_size
130+
if start_index < 0:
131+
start_index = 0
132+
end_index = self.current_index
133+
134+
# Use a 1-second window for peak detection
135+
filtered_window = filtered_eog[start_index:end_index]
136+
peaks = self.detect_peaks(filtered_window, threshold)
137+
138+
# Mark detected peaks and store them with timestamps
139+
for peak in peaks:
140+
full_peak_index = start_index + peak
141+
self.detected_peaks.append((full_peak_index, time.time())) # Add detected peak with current timestamp
142+
143+
def detect_peaks(self, signal, threshold):
144+
peaks = []
145+
prev_peak_time = None # Variable to store the timestamp of the previous peak
146+
min_peak_gap = 0.1 # Minimum time gap between two peaks in seconds
147+
148+
for i in range(1, len(signal) - 1):
149+
if signal[i] > signal[i - 1] and signal[i] > signal[i + 1] and signal[i] > threshold:
150+
current_peak_time = i / self.sampling_rate # Time in seconds based on the sampling rate
151+
152+
if prev_peak_time is not None:
153+
time_gap = current_peak_time - prev_peak_time
154+
if time_gap < min_peak_gap:
155+
continue
156+
157+
peaks.append(i)
158+
prev_peak_time = current_peak_time
78159

79-
# Update curve with the low-pass filtered EOG signal
80-
self.eog_curve.setData(self.time_data, filtered_eog)
160+
return peaks
81161

82162
if __name__ == "__main__":
83163
app = QApplication(sys.argv)
84164
window = EOGMonitor()
165+
print("Note: There will be a 2s calibration delay before peak detection starts.")
85166
window.show()
86167
sys.exit(app.exec_())

applications/ffteeg.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pyqtgraph as pg
77
import pylsl
88
import sys
9-
from scipy.signal import butter, filtfilt
9+
from scipy.signal import butter, filtfilt, iirnotch
1010
from scipy.fft import fft
1111

1212
class EEGMonitor(QMainWindow):
@@ -27,7 +27,7 @@ def __init__(self):
2727
self.eeg_plot_widget.setLabel('bottom', 'EEG Plot')
2828
self.eeg_plot_widget.setYRange(0, 15000, padding=0)
2929
self.eeg_plot_widget.setXRange(0, 10, padding=0)
30-
self.eeg_plot_widget.setMouseEnabled(x=False, y=False) # Disable zoom
30+
self.eeg_plot_widget.setMouseEnabled(x=False, y=True) # Disable zoom
3131
self.main_layout.addWidget(self.eeg_plot_widget)
3232

3333
# Second half for FFT and Brainwave Power, aligned horizontally
@@ -77,6 +77,10 @@ def __init__(self):
7777
self.time_data = np.linspace(0, 10, self.buffer_size) # Fixed time array for plotting
7878
self.current_index = 0 # Index for overwriting data
7979

80+
# Notch filter at 50 Hz
81+
self.b_notch, self.a_notch = iirnotch(50, 30, self.sampling_rate)
82+
# High-pass filter (cutoff at 0.5 Hz)
83+
self.b_highpass, self.a_highpass = butter(4, 0.5 / (0.5 * self.sampling_rate), btype='high')
8084
# Low-pass filter (4th order, cutoff at 45 Hz)
8185
self.b_lowpass, self.a_lowpass = butter(4, 45 / (0.5 * self.sampling_rate), btype='low')
8286

@@ -96,15 +100,27 @@ def update_plot(self):
96100
self.eeg_data[self.current_index] = sample[0]
97101
self.current_index = (self.current_index + 1) % self.buffer_size # Circular increment
98102

99-
# Apply low-pass filter
100-
filtered_eeg = filtfilt(self.b_lowpass, self.a_lowpass, self.eeg_data)
103+
if self.current_index >= self.buffer_size:
104+
plot_data = self.eeg_data
105+
else:
106+
plot_data = np.concatenate((self.eeg_data[self.current_index:], self.eeg_data[:self.current_index]))
107+
108+
# Apply filters to the full data for EEG plot
109+
filtered_eeg = filtfilt(self.b_notch, self.a_notch, plot_data)
110+
filtered_eeg = filtfilt(self.b_highpass, self.a_highpass, filtered_eeg)
111+
filtered_eeg = filtfilt(self.b_lowpass, self.a_lowpass, filtered_eeg)
101112

102113
# Update the EEG plot with the filtered data
103114
self.eeg_curve.setData(self.time_data, filtered_eeg)
104115

105-
# Perform FFT with windowing (Hamming window)
106-
window = hamming(len(filtered_eeg)) # Apply Hamming window to reduce spectral leakage
107-
filtered_eeg_windowed = filtered_eeg * window # Element-wise multiply
116+
# Perform FFT on the latest 1-second slice
117+
latest_data = plot_data[-self.sampling_rate:] # Most recent 1-second data
118+
window = hamming(len(latest_data))
119+
filtered_eeg_windowed = latest_data * window
120+
121+
# Apply zero-padding
122+
zero_padded_length = 2048
123+
filtered_eeg_windowed_padded = np.pad(filtered_eeg_windowed, (0, zero_padded_length - len(filtered_eeg_windowed)), 'constant')
108124

109125
eeg_fft = np.abs(fft(filtered_eeg_windowed))[:len(filtered_eeg_windowed) // 2] # Positive frequencies only
110126
freqs = np.fft.fftfreq(len(filtered_eeg_windowed), 1 / self.sampling_rate)[:len(filtered_eeg_windowed) // 2]

0 commit comments

Comments
 (0)