1
- import sys
2
1
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
71
10
72
- class ECGApp ( QtWidgets . QMainWindow ):
73
- def __init__ (self ):
11
+ class ECGMonitor ( QMainWindow ):
12
+ def __init__ (self ):
74
13
super ().__init__ ()
75
14
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 )
79
19
self .plot_widget .setBackground ('w' )
20
+ self .plot_widget .showGrid (x = True , y = True )
80
21
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 )
85
26
86
- layout = QtWidgets . QVBoxLayout ()
27
+ layout = QVBoxLayout ()
87
28
layout .addWidget (self .plot_widget )
88
29
layout .addWidget (self .heart_rate_label )
89
30
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 )
104
34
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 ])
147
41
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" )
154
112
else :
155
- self .heart_rate_label .setText ("Heart rate: 0 bpm" )
113
+ self .heart_rate_label .setText ("Heart Rate: Calculating..." )
156
114
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
165
119
166
120
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 ()
179
123
window .show ()
180
-
181
- sys .exit (app .exec_ ())
124
+ sys .exit (app .exec_ ())
0 commit comments