One of the most straightforward aspects of ECG analysis is to detect QRS complexes, which are the super obvious "peaks" that occur regularly in the signal. Each represents one heartbeat, and therefore extracting them can yield valuable information for downstream analysis. It's also generally the first step of all automated ECG analysis.
The most widely used algorithm for QRS detection is the Pan Tompkins algorithm.
I'm going to walk through the algorithm on the motion artifact data since it's simpler, and then apply it to the segment of arrhythmia data.
using CSV, Plots, Images #, TimeSeries
using StatsBase
plotlyjs()
fs_motion = 500;
sample_motion = CSV.read("test22_00j.csv", header = ["Time", "ECG1", "ECG2", "ECG3", "ECG4"], datarow=1, nullable=false)
plot(sample_motion[:,1],sample_motion[:,4],title = "Sample ECG during Jumping with Motion Artifact",xlabel="Time (sec)")
fs = fs_motion
T = 1/fs
x = sample_motion[:,4];
#Mean shift the signal
x .= x .- mean(x);
Low-pass Filter
x_LP = zeros(size(x));
x_LP[1:12] = x[1:12];
for n = 13:length(x_LP)
x_LP[n] = 2x_LP[n-1] - x_LP[n-2] + x[n] - 2x[n-6] + x[n-12]
end
plot(sample_motion[:,1],x_LP,title = "Sample ECG after Low-Pass Filter",xlabel="Time (sec)")
High-pass Filter
x_HP = zeros(size(x))
for n = 33:length(x_HP)
x_HP[n] = 32x_LP[n-16] - (x_HP[n-1] + x_LP[n] - x_LP[n-32])
end
plot(sample_motion[:,1],x_HP,title = "Sample ECG after Band-Pass Filter",xlabel="Time (sec)")
Derivative
x_D = zeros(size(x))
for n = 3:length(x_D)-2
x_D[n] = (1/(8*T)) * (-x_HP[n-2] - 2x_HP[n-1] + 2x_HP[n+1] + x_HP[n+2])
end
plot(sample_motion[:,1],x_D,title = "Sample ECG after Derivative",xlabel="Time (sec)")
Squaring
x_S = x_D.^2
plot(sample_motion[:,1],x_S,title = "Sample ECG after Derivative Squaring",xlabel="Time (sec)")
Moving-Window Integration (Moving Average Filter)
We want the width of the window to be such that it encompasses an entire QRS complex, but not too much more. Normally, QRS complexes are about 100 ms wide, but in the case of some pathologies, can be up to 150 ms. We set the window to be 150 ms wide.
w = Int(fld(0.150,T))
x_MAF = zeros(length(x)-w+1,)
for n = 1:length(x_MAF)
x_MAF[n] = mean(x_S[n:n+w-1])
end
x_MAF = [x_MAF[1]*ones(w-1,1); x_MAF]
plot(sample_motion[:,1],x_MAF,title = "Sample ECG after Derivative Squaring and Moving Average Filter",xlabel="Time (sec)")
Now superimposing that with the delayed original signal (total delay = 24 samples from first three steps = 6+16+2) (Amplitude adjusted to be visible on the plot)
x_delay = x
x_delay[1:24] = x[1]
x_delay[25:end] = x[1:end-24]
plot!(sample_motion[:,1],100000000x_delay)
The QRS complexes correspond to the rising edge of each of the "bumps" in moving average filtered signal. After shifting the original signal by the total delay (above), you can see that the QRS complexes line up with the rising edges.
Fiducial Marks
Find local maxima and make sure they are at least 200 ms apart using the existing Julia function for finding local maxima.
locs_cart = findlocalmaxima(x_MAF)
locs_test = [idx[1] for idx in locs_cart]
pks_test = x_MAF[locs_test];
plot(sample_motion[:,1],x_MAF,title = "MAF Signal",xlabel="Time (sec)")
plot!(sample_motion[locs_test,1],pks_test,line = :scatter)
Clearly wayyy too many peaks... implement the findpeaks function from MATLAB that allows for specification of more parameters to define peak such as min peak distance.
include("findpeaks.jl")
#Adjust filtered signal
x_HP .= x_HP .- sample_motion[:,1].*((x_HP[end]-x_HP[1])/(sample_motion[end,1]-sample_motion[1,1]));
x_HP .= x_HP .- mean(x_HP);
pks,locs = findpeaks(x_MAF,[],Int(0.2/T),[],[])
plot(sample_motion[:,1],x_MAF,title = "MAF Signal with Peaks Identified",xlabel="Time (sec)")
plot!(sample_motion[locs,1],pks,line = :scatter)
Yay! Success! Though the timepoints are not actually perfectly at the beginning of the plateaus.... (Zoomed in and checked)
Use the first 2 seconds of signal to determine thresholds for signal and noise for the INTEGRATION WAVEFORM
These just set the initial thresholds for signal and noise that will then be adjusted as the peaks are found.
THR_SIG = maximum(x_MAF[1:2*fs])*0.25; # 0.25 of the max amplitude
THR_NOISE = mean(x_MAF[1:2*fs])*0.5; # 0.5 of the mean signal is considered to be noise
SPKI = THR_SIG;
NPKI = THR_NOISE;
Use the first 2 seconds of signal to determine thresholds for signal and noise for the FILTERED WAVEFORM
THR_SIG1 = maximum(x_HP[1:2*fs])*0.25; # 0.25 of the max amplitude
THR_NOISE1 = mean(x_HP[1:2*fs])*0.5;
SPKF = THR_SIG1; # Signal level in Bandpassed filter
NPKF = THR_NOISE1; # Noise level in Bandpassed filter
Thresholding and Online Decision Rule
This just goes through and checks each peak against the previous 8 to see if it should still be considered a peak or not.
# Initialize lots of stuff
qrs_c =zeros(0) #amplitude of R
qrs_i = zeros(Int64,0) #index
y_i = zeros(0)
x_i = zeros(Int64,0)
SIG_LEV = 0;
nois_c =zeros(0)
nois_i = zeros(Int64,0);
Tskip = 0; # becomes one when a T wave is detected
not_nois = 0; # it is not noise when not_nois = 1
selected_RR = zeros(0) # Selected RR intervals
m_selected_RR = 0;
mean_RR = 0;
qrs_i_raw = zeros(Int64,0);
qrs_amp_raw = zeros(0)
ser_back = 0;
test_m = 0;
SIGL_buf = zeros(0)
NOISL_buf = zeros(0)
THRS_buf = zeros(0)
SIGL_buf1 = zeros(0)
NOISL_buf1 = zeros(0)
THRS_buf1 = zeros(0)
ax = zeros(1,6);
locs_temp = zeros(Int64,0)
x_i_t = zeros(Int64,0);
for i = 1:length(pks)
#Locate the corresponding peak in the filtered signal
if (locs[i]-round(0.150*fs) >= 1) && (locs[i] <= length(x_HP))
y_i = maximum(x_HP[Int(locs[i]-round(0.150*fs)):locs[i]]) #Since I pad to make the same length, should line up exactly
x_i = indmax(x_HP[Int(locs[i]-round(0.150*fs)):locs[i]])
else
if i == 1
y_i = maximum(x_HP[1:locs[i]])
x_i = indmax(x_HP[1:locs[i]])
ser_back = 1
elseif locs[i] >= length(x_HP)
y_i = maximum(x_HP[Int(locs[i]-round(0.150*fs)):length(x_HP)])
x_i = indmax(x_HP[Int(locs[i]-round(0.150*fs)):length(x_HP)])
end
end
#Update the heart_rate (last 8 and selected last 8)
if length(qrs_c) > 8
diffRR = diff(qrs_i[length(qrs_i)-8:length(qrs_i)]); #calculate length of last 8 RR intervals
mean_RR = mean(diffRR); #calculate the mean of last 8 RR intervals
comp = qrs_i[length(qrs_i)]-qrs_i[length(qrs_i)-1]; #most recent RR
if comp <= 0.92*mean_RR || comp >= 1.16*mean_RR
# lower down thresholds to detect better in Integrated Waveform
THR_SIG *= 0.5;
#THR_NOISE = 0.5*(THR_SIG);
# lower down thresholds to detect better in Filtered Waveform
THR_SIG1 *= 0.5;
#THR_NOISE1 = 0.5*(THR_SIG1);
else
m_selected_RR = mean_RR; #Shouldn't this be if ALL 8 are within those bounds, not just the last one?
end
end
#Calculate the mean of the last 8 R waves to make sure that QRS is not missing (If no R detected, trigger a search back)
if m_selected_RR > 0
test_m = m_selected_RR;
elseif mean_RR > 0 && m_selected_RR == 0 #else if the regular RR available use it
test_m = mean_RR;
else
test_m = 0;
end
if test_m > 0
if (locs[i] - qrs_i[length(qrs_i)]) >= round(1.66*test_m) #A QRS is missed
pks_temp = maximum(x_MAF[Int(qrs_i[length(qrs_i)] + round(0.200*fs)):Int(locs[i] - round(0.200*fs))]) #search back and locate the max in this interval
locs_temp = indmax(x_MAF[Int(qrs_i[length(qrs_i)] + round(0.200*fs)):Int(locs[i] - round(0.200*fs))])
locs_temp = Int(qrs_i[length(qrs_i)] + round(0.200*fs) + locs_temp - 1); #Compute actual index of location in overall signal
if pks_temp > THR_NOISE #As long as it passes the noise barrier
qrs_c = append!(qrs_c, pks_temp)
qrs_i = append!(qrs_i, locs_temp);
#Find the location in filtered sig
if locs_temp <= length(x_HP)
y_i_t = maximum(x_HP[Int(locs_temp-round(0.150*fs)):locs_temp])
x_i_t = indmax(x_HP[Int(locs_temp-round(0.150*fs)):locs_temp])
else
y_i_t = maximum(x_HP[Int(locs_temp-round(0.150*fs)):length(x_HP)])
x_i_t = indmax(x_HP[Int(locs_temp-round(0.150*fs)):length(x_HP)])
end
#Take care of bandpass signal threshold
if y_i_t > THR_NOISE1
qrs_i_raw = append!(qrs_i_raw, Int(locs_temp-round(0.150*fs) + (x_i_t - 1))) #Save actual index on filtered signal
qrs_amp_raw = append!(qrs_amp_raw, y_i_t) #Save amplitude on filtered signal
SPKF = 0.25*y_i_t + 0.75*SPKF; #From second thresh - shouldn't it be overall peak for the first part
end
not_nois = 1;
SPKI = 0.25*pks_temp + 0.75*SPKI #When found with the second threshold
end
else
not_nois = 0;
end
end
#Find noise and QRS peaks
if pks[i] >= THR_SIG
#If current QRS candidate occurs within 360 ms of the previous QRS,the algorithm determines if it's T wave or QRS
if length(qrs_c) >= 3 #More than three previous ones detected already
if (locs[i]-qrs_i[length(qrs_i)]) <= round(0.360*fs) #Within 360ms of last peak
Slope1 = mean(diff(x_MAF[Int(locs[i]-round(0.075*fs)):locs[i]])); #Mean slope of the waveform at that position based on 75 ms period before
Slope2 = mean(diff(x_MAF[Int(qrs_i[length(qrs_i)]-round(0.075*fs)):qrs_i[length(qrs_i)]])); #Mean slope of previous peak
if abs(Slope1) <= abs(0.5*(Slope2)) #Slope less then 0.5 of previous peak
nois_c = append!(nois_c, pks[i]); #Add it to noise
nois_i = append!(nois_i, locs[i]);
Tskip = 1; #T wave identification FOR LATER - KEEP TRACK OF LOCATIONS OF T WAVES AS WELL
#Adjust noise level in both filtered and integrated signal
NPKF = 0.125*y_i + 0.875*NPKF;
NPKI = 0.125*pks[i] + 0.875*NPKI;
else
Tskip = 0;
end
end
end
#If not a T wave
if Tskip == 0 #Tskip is 1 when a T wave is detected - so NOT a T wave
qrs_c = append!(qrs_c, pks[i]) #Save the peak
qrs_i = append!(qrs_i, locs[i])
#Filtered signal - check threshold
if y_i >= THR_SIG1
if ser_back == 1 #Peak at the very beginning
qrs_i_raw = append!(qrs_i_raw, x_i) #Save index on filtered signal
else
qrs_i_raw = append!(qrs_i_raw, Int(locs[i]-round(0.150*fs) + (x_i - 1))) #Save index on filtered signal
end
qrs_amp_raw = append!(qrs_amp_raw, y_i) #Save amplitude on filtered signal
SPKF = 0.125*y_i + 0.875*SPKF #Adjust threshold for bandpass filtered sig
end
#Adjust Signal level
SPKI = 0.125*pks[i] + 0.875*SPKI
end
elseif (THR_NOISE <= pks[i]) && (pks[i] < THR_SIG) #Higher than noise, but not high enough for peak
#Adjust Noise level in filtered signal
NPKF = 0.125*y_i + 0.875*NPKF
#Adjust Noise level in integrated signal
NPKI = 0.125*pks[i] + 0.875*NPKI
elseif pks[i] < THR_NOISE #Under noise threshold
nois_c = append!(nois_c, pks[i]); #Add it to noise
nois_i = append!(nois_i, locs[i]);
#Adjust noise level in filtered signal
NPKF = 0.125*y_i + 0.875*NPKF;
#Adjust noise level in integrated signal
NPKI = 0.125*pks[i] + 0.875*NPKI;
end
#Adjust the threshold with SNR
if NPKI != 0 || SPKI != 0
THR_SIG = NPKI + 0.25*(abs(SPKI - NPKI));
THR_NOISE = 0.5*(THR_SIG);
end
#Adjust the threshold with SNR for filtered signal
if NPKF != 0 || SPKF != 0
THR_SIG1 = NPKF + 0.25*(abs(SPKF - NPKF));
THR_NOISE1 = 0.5*(THR_SIG1);
end
#Keep track of thresholds of integrated signal
SIGL_buf = append!(SIGL_buf, SPKI);
NOISL_buf = append!(NOISL_buf, NPKI);
THRS_buf = append!(THRS_buf, THR_SIG);
#Keep track of thresholds of filtered signal
SIGL_buf1 = append!(SIGL_buf1, SPKF);
NOISL_buf1 = append!(NOISL_buf1, NPKF);
THRS_buf1 = append!(THRS_buf1, THR_SIG1);
Tskip = 0; #reset parameters
not_nois = 0; #reset parameters
ser_back = 0; #reset bandpass param
end
Plotting the Final Results
plot(sample_motion[:,1],x_MAF,title = "MAF Signal after Algorithm",xlabel="Time (sec)")
plot!(sample_motion[qrs_i,1],qrs_c,line = :scatter)
plot(sample_motion[:,1],x_HP,title = "Filtered Signal after Algorithm",xlabel="Time (sec)")
plot!(sample_motion[qrs_i_raw,1],qrs_amp_raw,line = :scatter)
Yay!!!!
So what the heck do all the other parameters that we were keeping track of contain?
Looking at the signal and noise levels and thresholds
plot(sample_motion[:,1],x_MAF,title = "MAF Signal after Algorithm",xlabel="Time (sec)",label = "Integrated Signal")
plot!(sample_motion[qrs_i,1],qrs_c,line = :scatter, label = "Identified Peaks")
plot!(sample_motion[qrs_i,1],SIGL_buf, label = "Signal Level")
plot!(sample_motion[qrs_i,1],NOISL_buf, label = "Noise Level")
plot!(sample_motion[qrs_i,1],THRS_buf, label = "Threshold")
plot(sample_motion[:,1],x_HP,title = "Filtered Signal after Algorithm",xlabel="Time (sec)",label = "Filtered Signal")
plot!(sample_motion[qrs_i_raw,1],qrs_amp_raw,line = :scatter,label = "Identified Peaks")
plot!(sample_motion[qrs_i_raw,1],SIGL_buf1,label = "Signal Level")
plot!(sample_motion[qrs_i_raw,1],NOISL_buf1, label = "Noise Level")
plot!(sample_motion[qrs_i_raw,1],THRS_buf1, label = "Threshold")
So I combined everything so far into a single Pan Tompkins function...
include("pan_tompkins.jl")
include("run_pan_tompkins.jl")
fs_arrhyth = 360;
sample_arrhyth = CSV.read("207.csv",header = ["Time", "ECG1", "ECG2"], datarow=1, nullable=false)
plot(sample_arrhyth[360*515:360*535,1],sample_arrhyth[360*515:360*535,3],title = "Sample ECG during Arrhythmia",xlabel="Time (sec)")
#Test on arrhythmia sample
fs = fs_arrhyth;
x = sample_arrhyth[360*1700:360*1730,3];
t = sample_arrhyth[360*1700:360*1730,1];
x_HP, x_MAF, qrs_i, qrs_c, SIGL_buf, NOISL_buf, THRS_buf, qrs_i_raw, qrs_amp_raw, SIGL_buf1, NOISL_buf1, THRS_buf1 = run_pan_tompkins(x,t,fs);
#Plot results
#MAF Signal
plot(t,x_MAF,title = "MAF Signal after Algorithm",xlabel="Time (sec)",label = "Integrated Signal")
plot!(t[qrs_i],qrs_c,line = :scatter, label = "Identified Peaks")
plot!(t[qrs_i],SIGL_buf, label = "Signal Level")
plot!(t[qrs_i],NOISL_buf, label = "Noise Level")
plot!(t[qrs_i],THRS_buf, label = "Threshold")
#Filtered signal
plot(t,x_HP,title = "Filtered Signal after Algorithm",xlabel="Time (sec)",label = "Filtered Signal")
plot!(t[qrs_i_raw],qrs_amp_raw,line = :scatter,label = "Identified Peaks")
plot!(t[qrs_i_raw],SIGL_buf1,label = "Signal Level")
plot!(t[qrs_i_raw],NOISL_buf1, label = "Noise Level")
plot!(t[qrs_i_raw],THRS_buf1, label = "Threshold")
In this case, the signal and noise thresholds aren't particularly useful, but one could imagine a case where knowing the rough thresholds could help with calibration of equipment, or choosing settings etc.