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 )
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
+ from collections import deque
10
+ import sys
33
11
12
+ class ECGMonitor (QMainWindow ):
34
13
def __init__ (self ):
35
14
super ().__init__ ()
36
- self .running = True
37
- self .sampling_rate = None
38
15
39
- def run (self ):
40
- global inlet
41
- print ("Looking for LSL stream..." )
42
- streams = resolve_stream ('name' , 'BioAmpDataStream' )
16
+ # Set up GUI window
17
+ self .setWindowTitle ("Real-Time ECG Monitor" )
18
+ self .setGeometry (100 , 100 , 800 , 600 )
43
19
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
71
-
72
- class ECGApp (QtWidgets .QMainWindow ):
73
- def __init__ (self ):
74
- super ().__init__ ()
75
-
76
- # Create a plot widget
77
- self .plot_widget = pg .PlotWidget (title = "ECG Signal" )
78
- self .setCentralWidget (self .plot_widget )
20
+ self .plot_widget = PlotWidget (self )
79
21
self .plot_widget .setBackground ('w' )
80
-
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 )
85
-
86
- layout = QtWidgets .QVBoxLayout ()
22
+ self .plot_widget .showGrid (x = True , y = True )
23
+
24
+ # Add a label to display the heart rate in bold at the bottom center
25
+ self .heart_rate_label = QLabel (self )
26
+ self .heart_rate_label .setStyleSheet ("font-size: 20px; font-weight: bold; color: black;" )
27
+ self .heart_rate_label .setAlignment (Qt .AlignCenter )
28
+
29
+ layout = QVBoxLayout ()
87
30
layout .addWidget (self .plot_widget )
88
31
layout .addWidget (self .heart_rate_label )
89
32
90
- container = QtWidgets . QWidget ()
91
- container .setLayout (layout )
92
- self .setCentralWidget (container )
33
+ central_widget = QWidget ()
34
+ central_widget .setLayout (layout )
35
+ self .setCentralWidget (central_widget )
93
36
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
37
+ # Data and buffers
38
+ self .ecg_data = deque (maxlen = 2500 ) # 10 seconds of data at 250 Hz
39
+ self .time_data = deque (maxlen = 2500 )
40
+ self .r_peaks = []
41
+ self .heart_rate = None
97
42
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
104
-
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 ()
43
+ # Set up LSL stream inlet
44
+ print ("Looking for an ECG stream..." )
45
+ streams = pylsl .resolve_stream ('name' , 'BioAmpDataStream' )
46
+ if not streams :
47
+ print ("No LSL stream found!" )
48
+ sys .exit (0 )
49
+ self .inlet = pylsl .StreamInlet (streams [0 ])
147
50
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" )
51
+ # Sampling rate
52
+ self .sampling_rate = self .inlet .info ().nominal_srate ()
53
+ if self .sampling_rate == pylsl .IRREGULAR_RATE :
54
+ print ("Irregular sampling rate detected!" )
55
+ sys .exit (0 )
56
+ print (f"Sampling rate: { self .sampling_rate } Hz" )
57
+
58
+ # Timer for updating the GUI
59
+ self .timer = pg .QtCore .QTimer ()
60
+ self .timer .timeout .connect (self .update_plot )
61
+ self .timer .start (10 ) # Update every 10 ms
62
+
63
+ # Low-pass filter coefficients
64
+ self .b , self .a = butter (4 , 20.0 / (0.5 * self .sampling_rate ), btype = 'low' )
65
+
66
+ # Plot configuration
67
+ self .plot_window = 10 # Plot window of 10 seconds
68
+ self .buffer_size = self .plot_window * self .sampling_rate # 10 seconds at 250 Hz sampling rate
69
+
70
+ # Set y-axis limits based on sampling rate
71
+ if self .sampling_rate == 250 :
72
+ self .plot_widget .setYRange (0 , 2 ** 10 ) #for R3
73
+ elif self .sampling_rate == 500 :
74
+ self .plot_widget .setYRange (0 , 2 ** 14 ) #for R4
75
+
76
+ def update_plot (self ):
77
+ samples , timestamps = self .inlet .pull_chunk (timeout = 0.0 , max_samples = 32 )
78
+ if samples :
79
+ for sample , timestamp in zip (samples , timestamps ):
80
+ self .ecg_data .append (sample [0 ])
81
+ self .time_data .append (timestamp )
82
+
83
+ # Convert deque to numpy array for processing
84
+ ecg_array = np .array (self .ecg_data )
85
+ filtered_ecg = filtfilt (self .b , self .a , ecg_array ) # Apply low-pass filter
86
+ self .r_peaks = self .detect_r_peaks (filtered_ecg ) # Detect R-peaks using NeuroKit2
87
+ self .calculate_heart_rate () # Calculate heart rate
88
+
89
+ # Update plot immediately with whatever data is available
90
+ self .plot_widget .clear ()
91
+ current_time = np .linspace (0 , len (ecg_array )/ self .sampling_rate , len (ecg_array ))
92
+ self .plot_widget .setXRange (0 , self .plot_window ) # Fixed x-axis range
93
+ self .plot_widget .plot (current_time , filtered_ecg , pen = pg .mkPen ('k' , width = 1 ))
94
+
95
+ # Mark R-peaks on the plot
96
+ if len (self .r_peaks ) > 0 :
97
+ self .plot_widget .plot (current_time [self .r_peaks ], filtered_ecg [self .r_peaks ], pen = None , symbol = 'o' , symbolBrush = 'r' )
98
+
99
+ # Update heart rate display
100
+ if self .heart_rate :
101
+ self .heart_rate_label .setText (f"Heart Rate: { int (self .heart_rate )} BPM" )
102
+ else :
103
+ self .heart_rate_label .setText ("Heart Rate: Calculating..." )
154
104
else :
155
- self .heart_rate_label .setText ("Heart rate: 0 bpm " )
105
+ self .heart_rate_label .setText ("Heart Rate: Collecting data... " )
156
106
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
107
+ def detect_r_peaks (self , ecg_signal ):
108
+ # Using NeuroKit2 to detect R-peaks
109
+ r_peaks = nk .ecg_findpeaks (ecg_signal , sampling_rate = self .sampling_rate )
161
110
162
- def signal_handler (sig , frame ):
163
- print ("Exiting..." )
164
- QtWidgets .QApplication .quit ()
111
+ if 'ECG_R_Peaks' in r_peaks :
112
+ return r_peaks ['ECG_R_Peaks' ]
113
+ else :
114
+ print ("No R-peaks detected. Please check the input ECG signal." )
115
+ return []
116
+
117
+ def calculate_heart_rate (self ):
118
+ if len (self .r_peaks ) > 1 :
119
+ peak_times = np .array ([self .time_data [i ] for i in self .r_peaks ])
120
+ rr_intervals = np .diff (peak_times )
121
+ avg_rr_interval = np .mean (rr_intervals )
122
+ self .heart_rate = 60.0 / avg_rr_interval
123
+ else :
124
+ self .heart_rate = None
165
125
166
126
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 )
127
+ app = QApplication (sys .argv )
128
+ window = ECGMonitor ()
179
129
window .show ()
180
-
181
130
sys .exit (app .exec_ ())
0 commit comments