Skip to content

Commit 51395fd

Browse files
authored
Merge pull request #15 from PayalLakra/bio_amptool
hearbeat_ecg.py file update
2 parents 38ddc04 + 672400e commit 51395fd

File tree

5 files changed

+137
-176
lines changed

5 files changed

+137
-176
lines changed

app_requirements.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pyqtgraph==0.13.7
2+
PySide2==5.15.2.1
3+
keyboard==0.13.5
4+
scipy==1.14.1
5+
pygame==2.6.1
6+
neurokit2==0.2.10

applications/heartbeat_ecg.py

Lines changed: 107 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -1,181 +1,124 @@
1-
import sys
21
import numpy as np
3-
import time
4-
from pylsl import StreamInlet, resolve_stream
5-
from scipy.signal import butter, lfilter, find_peaks
6-
import pyqtgraph as pg # For real-time plotting
7-
from pyqtgraph.Qt import QtWidgets, QtCore # PyQt components for GUI
8-
import signal # For handling Ctrl+C
9-
from collections import deque # For creating a ring buffer
10-
11-
# Initialize global variables
12-
inlet = None
13-
data_buffer = np.zeros(2000) # Buffer to hold the last 2000 samples for ECG data
14-
15-
# Function to design a Butterworth filter
16-
def butter_filter(cutoff, fs, order=4, btype='low'):
17-
nyquist = 0.5 * fs # Nyquist frequency
18-
normal_cutoff = cutoff / nyquist
19-
b, a = butter(order, normal_cutoff, btype=btype, analog=False)
20-
return b, a
21-
22-
# Apply the Butterworth filter to the data
23-
def apply_filter(data, b, a):
24-
return lfilter(b, a, data)
25-
26-
# Function to detect heartbeats using peak detection
27-
def detect_heartbeats(ecg_data, sampling_rate):
28-
peaks, _ = find_peaks(ecg_data, distance=sampling_rate * 0.6, prominence=0.5) # Adjust as necessary
29-
return peaks
30-
31-
class DataCollector(QtCore.QThread):
32-
data_ready = QtCore.pyqtSignal(np.ndarray)
33-
34-
def __init__(self):
35-
super().__init__()
36-
self.running = True
37-
self.sampling_rate = None
38-
39-
def run(self):
40-
global inlet
41-
print("Looking for LSL stream...")
42-
streams = resolve_stream('name', 'BioAmpDataStream')
43-
44-
if not streams:
45-
print("No LSL Stream found! Exiting...")
46-
sys.exit(0)
47-
48-
inlet = StreamInlet(streams[0])
49-
self.sampling_rate = inlet.info().nominal_srate()
50-
print(f"Detected sampling rate: {self.sampling_rate} Hz")
51-
52-
# Create and design filters
53-
low_cutoff = 20.0 # 20 Hz low-pass filter
54-
self.low_b, self.low_a = butter_filter(low_cutoff, self.sampling_rate, order=4, btype='low')
55-
56-
while self.running:
57-
# Pull multiple samples at once
58-
samples, _ = inlet.pull_chunk(timeout=0.0, max_samples=10) # Pull up to 10 samples
59-
if samples:
60-
global data_buffer
61-
data_buffer = np.roll(data_buffer, -len(samples)) # Shift data left
62-
data_buffer[-len(samples):] = [sample[0] for sample in samples] # Add new samples to the end
63-
64-
filtered_data = apply_filter(data_buffer, self.low_b, self.low_a) # Low-pass Filter
65-
self.data_ready.emit(filtered_data) # Emit the filtered data for plotting
66-
67-
time.sleep(0.01)
68-
69-
def stop(self):
70-
self.running = False
2+
from scipy.signal import butter, filtfilt
3+
from PyQt5.QtWidgets import QApplication, QVBoxLayout, QLabel, QMainWindow, QWidget
4+
from PyQt5.QtCore import Qt
5+
from pyqtgraph import PlotWidget
6+
import pyqtgraph as pg
7+
import pylsl
8+
import neurokit2 as nk
9+
import sys
7110

72-
class ECGApp(QtWidgets.QMainWindow):
73-
def __init__(self):
11+
class ECGMonitor(QMainWindow):
12+
def __init__(self):
7413
super().__init__()
7514

76-
# Create a plot widget
77-
self.plot_widget = pg.PlotWidget(title="ECG Signal")
78-
self.setCentralWidget(self.plot_widget)
15+
self.setWindowTitle("Real-Time ECG Monitor") # Set up GUI window
16+
self.setGeometry(100, 100, 800, 600)
17+
18+
self.plot_widget = PlotWidget(self)
7919
self.plot_widget.setBackground('w')
20+
self.plot_widget.showGrid(x=True, y=True)
8021

81-
# Create a label to display heart rate
82-
self.heart_rate_label = QtWidgets.QLabel("Heart rate: - bpm", self)
83-
self.heart_rate_label.setStyleSheet("font-size: 20px; font-weight: bold;")
84-
self.heart_rate_label.setAlignment(QtCore.Qt.AlignCenter)
22+
# Heart rate label at the bottom
23+
self.heart_rate_label = QLabel(self)
24+
self.heart_rate_label.setStyleSheet("font-size: 20px; font-weight: bold; color: black;")
25+
self.heart_rate_label.setAlignment(Qt.AlignCenter)
8526

86-
layout = QtWidgets.QVBoxLayout()
27+
layout = QVBoxLayout()
8728
layout.addWidget(self.plot_widget)
8829
layout.addWidget(self.heart_rate_label)
8930

90-
container = QtWidgets.QWidget()
91-
container.setLayout(layout)
92-
self.setCentralWidget(container)
93-
94-
self.ecg_buffer = []
95-
self.r_peak_times = deque(maxlen=10) # Deque to store recent R-peak times
96-
self.peak_intervals = deque(maxlen=9) # Deque to store intervals between R-peaks
97-
98-
self.data_collector = DataCollector() # Data collector thread
99-
self.data_collector.data_ready.connect(self.update_plot)
100-
self.data_collector.start()
101-
102-
self.time_axis = np.linspace(0, 2000/200, 2000) # Store the x-axis time window
103-
self.plot_widget.setYRange(0,1000) # Set fixed y-axis limits
31+
central_widget = QWidget()
32+
central_widget.setLayout(layout)
33+
self.setCentralWidget(central_widget)
10434

105-
# Time to start heart rate calculation after 10 seconds
106-
self.start_time = time.time()
107-
self.initial_period = 10 # First 10 seconds to gather enough R-peaks
108-
109-
self.total_interval_sum = 0 # To track the sum of intervals for efficient heart rate calculation
110-
111-
def update_plot(self, ecg_data):
112-
self.ecg_buffer = ecg_data # Update buffer
113-
self.plot_widget.clear()
114-
115-
# Set a fixed window (time axis) to ensure stable plotting
116-
self.plot_widget.plot(self.time_axis, self.ecg_buffer, pen='#000000') # Plot ECG data with black line
117-
self.plot_widget.setXRange(self.time_axis[0], self.time_axis[-1], padding=0)
118-
119-
heartbeats = detect_heartbeats(np.array(self.ecg_buffer), self.data_collector.sampling_rate)
120-
121-
for index in heartbeats:
122-
r_time = index / self.data_collector.sampling_rate
123-
self.r_peak_times.append(r_time)
124-
125-
if len(self.r_peak_times) > 1:
126-
# Calculate the interval between consecutive R-peaks
127-
new_interval = self.r_peak_times[-1] - self.r_peak_times[-2]
128-
129-
if len(self.peak_intervals) == self.peak_intervals.maxlen:
130-
# Remove the oldest interval from the sum
131-
oldest_interval = self.peak_intervals.popleft()
132-
self.total_interval_sum -= oldest_interval #Minus the oldest
133-
134-
# Add the new interval to the deque and update the sum
135-
self.peak_intervals.append(new_interval)
136-
self.total_interval_sum += new_interval # Plus the new
137-
138-
# Plot detected R-peaks
139-
r_peak_times = self.time_axis[heartbeats]
140-
r_peak_scatter = pg.ScatterPlotItem(x=r_peak_times, y=self.ecg_buffer[heartbeats],
141-
symbol='o', size=10, pen='r', brush='r')
142-
self.plot_widget.addItem(r_peak_scatter)
143-
144-
# Start heart rate calculation after 10 seconds
145-
if time.time() - self.start_time >= self.initial_period:
146-
self.update_heart_rate()
35+
# Set up LSL stream inlet
36+
streams = pylsl.resolve_stream('name', 'BioAmpDataStream')
37+
if not streams:
38+
print("No LSL stream found!")
39+
sys.exit(0)
40+
self.inlet = pylsl.StreamInlet(streams[0])
14741

148-
def update_heart_rate(self):
149-
if len(self.peak_intervals) > 0:
150-
# Efficiently calculate the heart rate using the sum of intervals
151-
avg_interval = self.total_interval_sum / len(self.peak_intervals)
152-
bpm = 60 / avg_interval # Convert to beats per minute
153-
self.heart_rate_label.setText(f"Heart rate: {bpm:.2f} bpm")
42+
# Sampling rate
43+
self.sampling_rate = int(self.inlet.info().nominal_srate())
44+
print(f"Sampling rate: {self.sampling_rate} Hz")
45+
46+
# Data and buffers
47+
self.buffer_size = self.sampling_rate * 10 # Fixed-size buffer for 10 seconds
48+
self.ecg_data = np.zeros(self.buffer_size) # Fixed-size array for circular buffer
49+
self.time_data = np.linspace(0, 10, self.buffer_size) # Fixed time array for plotting
50+
self.r_peaks = [] # Store the indices of R-peaks
51+
self.heart_rate = None
52+
self.current_index = 0 # Index for overwriting data
53+
54+
self.b, self.a = butter(4, 20.0 / (0.5 * self.sampling_rate), btype='low') # Low-pass filter coefficients
55+
56+
self.timer = pg.QtCore.QTimer() # Timer for updating the plot
57+
self.timer.timeout.connect(self.update_plot)
58+
self.timer.start(10)
59+
60+
# Set y-axis limits based on sampling rate
61+
if self.sampling_rate == 250:
62+
self.plot_widget.setYRange(0, 2**10) # for R3
63+
elif self.sampling_rate == 500:
64+
self.plot_widget.setYRange(0, 2**14) # for R4
65+
66+
# Set fixed x-axis range
67+
self.plot_widget.setXRange(0, 10) # 10 seconds
68+
69+
self.ecg_curve = self.plot_widget.plot(self.time_data, self.ecg_data, pen=pg.mkPen('k', width=1))
70+
self.r_peak_curve = self.plot_widget.plot([], [], pen=None, symbol='o', symbolBrush='r', symbolSize=10) # R-peaks in red
71+
72+
self.moving_average_window_size = 5 # Initialize moving average buffer
73+
self.heart_rate_history = [] # Buffer to store heart rates for moving average
74+
75+
def update_plot(self):
76+
samples, _ = self.inlet.pull_chunk(timeout=0.0, max_samples=30)
77+
if samples:
78+
for sample in samples:
79+
# Overwrite the oldest data point in the buffer
80+
self.ecg_data[self.current_index] = sample[0]
81+
self.current_index = (self.current_index + 1) % self.buffer_size # Circular increment
82+
83+
filtered_ecg = filtfilt(self.b, self.a, self.ecg_data) # Filter the signal
84+
85+
self.ecg_curve.setData(self.time_data, filtered_ecg) # Use current buffer for plotting
86+
87+
# Detect R-peaks and update heart rate
88+
self.r_peaks = self.detect_r_peaks(filtered_ecg)
89+
self.calculate_heart_rate()
90+
self.plot_r_peaks(filtered_ecg)
91+
92+
def detect_r_peaks(self, ecg_signal):
93+
r_peaks = nk.ecg_findpeaks(ecg_signal, sampling_rate=self.sampling_rate)
94+
return r_peaks['ECG_R_Peaks'] if 'ECG_R_Peaks' in r_peaks else []
95+
96+
def calculate_heart_rate(self):
97+
if len(self.r_peaks) >= 10: # Check if we have 10 or more R-peaks
98+
recent_r_peaks = self.r_peaks[-10:] # Use the last 10 R-peaks for heart rate calculation
99+
rr_intervals = np.diff([self.time_data[i] for i in recent_r_peaks]) # Calculate RR intervals (time differences between consecutive R-peaks)
100+
if len(rr_intervals) > 0:
101+
avg_rr = np.mean(rr_intervals) # Average RR interval
102+
self.heart_rate = 60.0 / avg_rr # Convert to heart rate (BPM)
103+
self.heart_rate_history.append(self.heart_rate) # Update moving average
104+
if len(self.heart_rate_history) > self.moving_average_window_size:
105+
self.heart_rate_history.pop(0) # Remove the oldest heart rate
106+
107+
# Calculate the moving average heart rate
108+
moving_average_hr = np.mean(self.heart_rate_history)
109+
110+
# Update heart rate label with moving average & convert into int
111+
self.heart_rate_label.setText(f"Heart Rate: {int(moving_average_hr)} BPM")
154112
else:
155-
self.heart_rate_label.setText("Heart rate: 0 bpm")
113+
self.heart_rate_label.setText("Heart Rate: Calculating...")
156114

157-
def closeEvent(self, event):
158-
self.data_collector.stop() # Stop the data collector thread on close
159-
self.data_collector.wait() # Wait for the thread to finish
160-
event.accept() # Accept the close event
161-
162-
def signal_handler(sig, frame):
163-
print("Exiting...")
164-
QtWidgets.QApplication.quit()
115+
def plot_r_peaks(self, filtered_ecg):
116+
r_peak_times = self.time_data[self.r_peaks] # Extract the time of detected R-peaks
117+
r_peak_values = filtered_ecg[self.r_peaks]
118+
self.r_peak_curve.setData(r_peak_times, r_peak_values) # Plot R-peaks as red dots
165119

166120
if __name__ == "__main__":
167-
signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C
168-
169-
streams = resolve_stream('name', 'BioAmpDataStream')
170-
if not streams:
171-
print("No LSL Stream found! Exiting...")
172-
sys.exit(0)
173-
174-
app = QtWidgets.QApplication(sys.argv)
175-
176-
window = ECGApp()
177-
window.setWindowTitle("Real-Time ECG Monitoring")
178-
window.resize(800, 600)
121+
app = QApplication(sys.argv)
122+
window = ECGMonitor()
179123
window.show()
180-
181-
sys.exit(app.exec_())
124+
sys.exit(app.exec_())

chords.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,13 @@ def send_command(ser, command):
111111
return response
112112

113113
# Function to read data from Arduino
114-
def read_arduino_data(ser, csv_writer=None):
114+
def read_arduino_data(ser, csv_writer=None, inverted=False):
115115
global total_packet_count, cumulative_packet_count, previous_sample_number, missing_samples, buffer, data
116+
117+
max_value = 2**14 if board == "UNO-R4" else 2**10
118+
min_value = 0
119+
mid_value = (max_value - 1) / 2
120+
116121
raw_data = ser.read(ser.in_waiting or 1) # Read available data from the serial port
117122
if raw_data == b'':
118123
send_command(ser, 'START')
@@ -149,7 +154,16 @@ def read_arduino_data(ser, csv_writer=None):
149154
high_byte = packet[2*channel + HEADER_LENGTH]
150155
low_byte = packet[2*channel + HEADER_LENGTH + 1]
151156
value = (high_byte << 8) | low_byte # Combine high and low bytes
152-
channel_data.append(float(value)) # Convert to float and add to channel data
157+
if inverted: # Apply inversion if the flag is set
158+
if value > mid_value:
159+
inverted_data = (mid_value) - abs(mid_value - value)
160+
elif value < mid_value:
161+
inverted_data = (mid_value) + abs(mid_value - value)
162+
else:
163+
inverted_data = value
164+
channel_data.append(float(inverted_data)) # Append the inverted value
165+
else:
166+
channel_data.append(float(value)) # Convert to float and add to channel data
153167

154168
if csv_writer: # If CSV logging is enabled, write the data to the CSV file
155169
csv_writer.writerow([counter] + channel_data)
@@ -195,7 +209,7 @@ def log_ten_minute_data(verbose=False):
195209
last_ten_minute_time = time.time() # Update the last 10-minute interval start time
196210

197211
# Main function to parse command-line arguments and handle data acquisition
198-
def parse_data(ser, lsl_flag=False, csv_flag=False, verbose=False, run_time=None):
212+
def parse_data(ser, lsl_flag=False, csv_flag=False, verbose=False, run_time=None, inverted= False):
199213
global total_packet_count, cumulative_packet_count, start_time, lsl_outlet, last_ten_minute_time, csv_filename
200214

201215
csv_writer = None # Placeholder for CSV writer
@@ -221,7 +235,7 @@ def parse_data(ser, lsl_flag=False, csv_flag=False, verbose=False, run_time=None
221235
send_command(ser, 'START')
222236

223237
while True:
224-
read_arduino_data(ser, csv_writer) # Read and process data from Arduino
238+
read_arduino_data(ser, csv_writer, inverted=inverted) # Read and process data from Arduino
225239
if(start_time is not None):
226240
current_time = time.time() # Get the current time
227241
elapsed_time = current_time - start_time # Time elapsed since the last second
@@ -291,13 +305,14 @@ def signal_handler(sig, frame):
291305
# Main entry point of the script
292306
def main():
293307
global verbose,ser
294-
parser = argparse.ArgumentParser(description="Upside Down Labs - BioAmp Tool",allow_abbrev = False) # Create argument parser
308+
parser = argparse.ArgumentParser(description="Upside Down Labs - Chords-Python Tool",allow_abbrev = False) # Create argument parser
295309
parser.add_argument('-p', '--port', type=str, help="Specify the COM port") # Port argument
296310
parser.add_argument('-b', '--baudrate', type=int, default=230400, help="Set baud rate for the serial communication") # Baud rate
297311
parser.add_argument('--csv', action='store_true', help="Create and write to a CSV file") # CSV logging flag
298312
parser.add_argument('--lsl', action='store_true', help="Start LSL stream") # LSL streaming flag
299313
parser.add_argument('-v', '--verbose', action='store_true', help="Enable verbose output with statistical data") # Verbose flag
300314
parser.add_argument('-t', '--time', type=int, help="Run the program for a specified number of seconds and then exit") #set time
315+
parser.add_argument('--inverted', action='store_true', help="Invert the signal before streaming LSL and logging") # Inverted flag
301316

302317
args = parser.parse_args() # Parse command-line arguments
303318
verbose = args.verbose # Set verbose mode
@@ -321,7 +336,7 @@ def main():
321336
return
322337

323338
# Start data acquisition
324-
parse_data(ser, lsl_flag=args.lsl, csv_flag=args.csv, verbose=args.verbose, run_time=args.time)
339+
parse_data(ser, lsl_flag=args.lsl, csv_flag=args.csv, verbose=args.verbose, run_time=args.time, inverted=args.inverted)
325340

326341
# Run the main function if this script is executed
327342
if __name__ == "__main__":

chords_requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
numpy==1.26.4
2+
pylsl==1.16.2
3+
pyserial==3.5

requirements.txt

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)