Мониторинг ЭКГ с использованием радара непрерывного излучения и глубокого обучения (Часть 1)
Введение
Современные радары всё активнее применяются для бесконтактного мониторинга жизненно важных функций, главные из которых являются дыхание и сердечная активность. В отличие от контактной регистрации (ЭКГ) радар не требует прямого взаимодействия с телом, а также работает при наличии препятствий и движениях пациента.
ЭКГ является «золотым стандартом» в диагностике, но её традиционное снятие неудобно для длительного регистрации. В этой демонстрации рассматривается задача восстановления формы ЭКГ из радарного сигнала, фиксирующего смесь микродвижения грудной клетки и активности сердца. В такой трактовке движение грудной клетки - помеха. В результате, задача сводится к выделению слабых кардиосигналов на фоне шумов и дыхания.
Демо-проект состоит из двух частей: в этом демо-примере анализируются и сравниваются признаки ЭКГ сигнала и отраженного эхо-радара (пики, интервалы, ЧСС), а во втором демо-примере обучается нейросеть, восстанавливающая форму ЭКГ по сигналу радару с последующим сравнением с эталонным ЭКГ сигналом.
О данных
В работе в качестве примера используется открытый набор данных, представленный Schellenberger et al. (Scientific Data, 2020). Этот датасет содержит ~24 часа синхронных записей радара и эталонных датчиков (ЭКГ, импедансная кардиография, непрерывное АД) от 30 здоровых добровольцев.
Из датасета был извлечен одно изменерие, над которым проводилось исследование
Импорт библиотек
Импортируем необходимые для работы библиотеки из Julia и Python ( skimage, scipy, neurokit2), а также импортируем в скрипт файл utils.jl, который содержит все необходимые функции
include("$(@__DIR__)/install_packages.jl");
Для работы скрипта необходимо установить несколько питоновских пакетов, функции которыъ вызываются через PyCall. Для установки необходимо перейти в командную строку и нажать символ ";". Далее откроется shell, куда нужно прописать:
pip install -r requirements.txt
важно находится в рабочей директории.
# ENV["PYTHON"] = '/user/.user_venvs/py3.11/bin'
using PyCall
using Measures
using Peaks
using Plots
using DSP
using Statistics
using Wavelets
skimage = pyimport("skimage");
nk = pyimport("neurokit2")
scipy = pyimport("scipy")
include("$(@__DIR__)/utils.jl");
Загрузка данных
Загрузим данные из файла с помощью функции load_data
, где i
- синфазная составляющая радарного сигнала, q
- квадратурная составляющая радарного сигнала, fs_radar
- частота дискретизации радарного сигнала, ecg_data1
, ecg_data2
- сигналы ЭКГ, записаные с использованием двух различных электродов, расположенных на теле испытуемого fs_ecg
- частота дискретизации сигнала ЭКГ
Dirpath = "$(@__DIR__)"
filepath = raw"GDN0011_1_Resting.mat"
order_filter = 6;
full_path = joinpath(Dirpath, filepath);
i, q, fs_radar, ecg_data1, ecg_data2, fs_ecg = load_data(full_path);
cur_ecg = vec(ecg_data2); # сигнал ЭКГ, который будем анализировать
Визуализируем сырые радарные и ЭКГ сигналы
p = plot(
layout = (4,1),
size = (800,800),
margin = 5mm,
bottom_margin = 15mm,
)
plot!(p, i[1:10000], subplot=1, label="I", xlabel="Отсчет", ylabel="Амплитуда, В")
plot!(p, q[1:10000], subplot=2, label="Q", xlabel="Отсчет", ylabel="Амплитуда, В")
plot!(p, ecg_data1[1:10000], subplot=3, label="1 отведение", xlabel="Отсчет", ylabel="U, мВ")
plot!(p, ecg_data2[1:10000], subplot=4, label="2 отведение", xlabel="Отсчет", ylabel="U, мВ")
Посмотрим как выглядит разность фаз и смещение грудной клетки для радарных данных без предобработки исходных сигналов
complex_radar_Data = Complex.(i, q);
sigma = angle.(complex_radar_Data);
lambda_ = 12.5e-3;
range_ = unwrap(sigma) ./ (4 * pi) * lambda_;
plot(layout = (2, 1), size=(600,600),margin=5mm)
plot!(sigma[10000:20000] .* (180/π), subplot=1, label="Разность фаз", xlabel="Отсчет", ylabel="Разность фаз, градусы", legendfontsize = 10)
plot!(range_[10000:20000] .*1e3, subplot=2, label="Смещение грудной клетки", xlabel="Отсчет", ylabel="Расстояние, мм", legendfontsize = 10)
Анализ сигнала с радара
Функция EllipseReconstruction(i, q)
нормализует IQ-сигнал радара: аппроксимирует его эллипсом, центрирует, поворачивает и масштабирует в окружность, после чего извлекает фазу и переводит её в микросмещения цели. Это позволяет получить чистый сигнал перемещения, из которого затем выделяются дыхание и пульс c помощью функции getVitalSignals
.
I_comp, Q_comp, sigma, range_ = EllipseReconstruction(i, q);
radar_respiration, radar_pulse, radar_heartsound, radar_heartsound_denoise = getVitalSignals(range_, fs_radar, order_filter);
Поскольку сигналы достаточно большие, для визуальной оценки визуализируем извлеченыне пульс, дыхание, сердченые звуки на интервале [1, 10000]
plot(layout = (2, 2), size=(800,500), margin=5mm)
plot!(radar_respiration[1:10000], subplot=1, label="Дыхание", xlabel="Отсчет", ylabel="Амплитуда, мВ")
plot!(radar_pulse[1:10000], subplot=2, label="Пульс", xlabel="Отсчет", ylabel="Амплитуда, мВ")
plot!(radar_heartsound[1:10000], subplot=3, label="Звуки сердца", xlabel="Отсчет", ylabel="Амплитуда, мВ")
plot!(radar_heartsound_denoise[1:10000], subplot=4, label="Звуки сердца после фильтра", xlabel="Отсчет", ylabel="Амплитуда, мВ")
На левом верхнем графике показан цикл дыхания с медленной синусоидальной формой, радарный сигнал звука сердечной активности (внизу) с выраженными высокочастотными пиками. На правом верхнем графике отображены пульсовой компонент и отфильтрованные звуки сердечного колебания.
Поиск R пиков на радарном сигнале
Далее вычислим ускорение движения грудной клетки, отфильтруем его на полосе [4, 20]
Гц
acc = diff(diff(radar_pulse)) * fs_radar^2;
radar_pulse_acc = Filter(acc, 4, 20, fs_radar, 6);
time_acc = range(0, step=1/fs_radar, length=length(radar_pulse_acc));
Визуализируем сигнал ускорения грудной клетки на интервале [10000, 40000]
Гц
plot(time_acc[10000:40000], radar_pulse_acc[10000:40000], xlabel="Время (с)", ylabel="Ускорение, м/с^2", title="Ускорение грудной клетки", label="")
Далее из радарного пульсового сигнала вычисляется амплитудная огибающая с помощью преобразования Гильберта, после чего в ней ищутся сердечные пики — только те, что достаточно высокие (выше 20% медианы) и разделены минимум на 0.76 секунды.
env = abs.(hilbert(radar_pulse_acc));
radar_peacks, _ = scipy.signal.find_peaks(env,
distance=Int(fs_radar * 0.76), height=median(env) * 0.2);
Отобразим фрагмент радарного сигнала: чёрная линия показывает огибающую, оранжевая — пульсовую компоненту (ускорение грудной клетки), а красные точки отмечают найденные R-пики в пределах выделенного временного окна.
time_start = 80000; time_end = 100000;
r_subset = filter(x -> time_start ≤ x ≤ time_end, radar_peacks)
radar_part = env[time_start:time_end]
time_part = time_acc[time_start:time_end]
time_peaks = time_acc[r_subset]
plot(time_part, radar_part;
color = :black,
lw = 1.5,
label = "Огибающая",
xlabel = "Время, с",
ylabel = "Амплитуда, м/с^2",
title = "Полный фрагмент"
)
plot!(time_part, radar_pulse_acc[time_start:time_end];
label = "Ускорение грудной клетки"
)
scatter!(time_peaks, env[r_subset];
marker = (:circle, 6),
mc = :red,
label = "R-пики"
)
Анализ ЭКГ
Далее проанализируем имеющийся ЭКГ сигнал
Из ЭКГ-сигнала сначала удаляются шумы (ecg_clean)
, затем находятся положения R-пиков (ecg_peaks)
, после чего выполняется поиск пиков P
, Q
, S
и T
с помощью метода peak
ecg_cleaned = nk.ecg_clean(cur_ecg, sampling_rate=fs_ecg);
signals, rpeaks = nk.ecg_peaks(ecg_cleaned, sampling_rate=fs_ecg);
_, waves = nk.ecg_delineate(ecg_cleaned, rpeaks, sampling_rate=fs_ecg, method="peak");
Визуализируем участок ЭКГ с пиками P
, Q
, R
, S
, T
волн с помощью кастомной функции, определенной в файле utils.py
plot_ecg_with_peaks(ecg_cleaned, waves, rpeaks; range_start=90000, range_end=100000)
rr_intervals_ecg = diff(rpeaks["ECG_R_Peaks"]) / fs_ecg;
times_ecg = cumsum([0.0; rr_intervals_ecg]);
rr_intervals_radar = diff(radar_peacks) / fs_radar;
times_radar = cumsum([0.0; rr_intervals_radar]);
scatter(
times_radar[2:end], rr_intervals_radar;
label = "RR радар",
xlabel = "Отсчет",
ylabel = "RR",
title = "RR радар & ЭКГ",
color = :green
)
scatter!(
times_ecg[2:end], rr_intervals_ecg;
label = "RR ЭКГ",
color = :red
)
print("Кол-во RR интервалов на радарном сигнале: ", length(rr_intervals_radar), "\n")
print("Кол-во RR интервалов на ЭКГ: ", length(rr_intervals_ecg))
На ритмограмме красные точки (RR по ЭКГ) и зелёные (RR по радару) в основном лежат вплотную друг к другу вокруг, что говорит об общей хорошей сходимости интервалов. При этом у радара встречаются рассеянные выбросы вверх (до ~1.9 с), а у ЭКГ — вниз (до ~0.6 с), что указывает на редкие пропуски или ложные срабатывания при детекции. В средней части (примерно от 100 до 400 отсчёта) кластеры особенно плотные и согласованы. В целом между RR_radar и RR_ECG наблюдается высокая согласованность
Далее рассчитаем мгновенную частоту сердечных сокращений для радарных и ЭКГ-данных, а затем из полученных HR вычисляются базовые статистики — среднее, медиана, минимальное и максимальное значения, что позволяет сравнить усреднённый пульс и разброс ЧСС в обеих методиках.
HR_radar = 60 .÷ rr_intervals_radar;
HR_ecg = 60 .÷ rr_intervals_ecg;
mean_HRRadar = mean(HR_ecg)
median_HRRadar = median(HR_ecg)
min_HRRadar = minimum(HR_ecg)
max_HRRadar = maximum(HR_ecg)
mean_HRECG = mean(HR_radar)
median_HRECG = median(HR_radar)
min_HRECG = minimum(HR_radar)
max_HRECG = maximum(HR_radar);
println("Статистика HR (уд/мин)")
println("──────────────────────────────")
println(" | ECG | Radar")
println("─────────────┼─────────────┼────────────")
println("Mean HR | ", round(mean_HRECG, digits=2), " | ", round(mean_HRRadar, digits=2))
println("Median HR | ", round(median_HRECG, digits=2), " | ", round(median_HRRadar, digits=2))
println("Min HR | ", round(min_HRECG, digits=2), " | ", round(min_HRRadar, digits=2))
println("Max HR | ", round(max_HRECG, digits=2), " | ", round(max_HRRadar, digits=2))
В целом согласованность неплохая — средние и медианные значения ЧСС близки (≈58 vs ≈62 уд/мин), но у радарного метода заметно больше разброс: минимумы и максимумы значительно выше, что говорит о ложных пиках или пропусках в детекции.
Анализ вариабельности сердечного ритма (HRV)
Можно получить полный набор показателей HRV — от простых статистических метрик до продвинутых нелинейных и мульти–фрактальных индексов с помощью функции hrv
из библиотеки neuroit2
.
hrv_indices = nk.hrv(rpeaks, sampling_rate=fs_ecg, show=false)
Визуализируем несколько полученных статистик
time_keys = ["HRV_SDNN", "HRV_RMSSD", "HRV_SDSD", "HRV_pNN50", "HRV_pNN20"]
freq_keys = ["HRV_VLF", "HRV_LF", "HRV_HF", "HRV_LFHF"]
poinc_keys = ["HRV_SD1", "HRV_SD2"]
geom_keys = ["HRV_TINN", "HRV_HTI"]
ent_keys = ["HRV_SampEn", "HRV_ApEn"]
getval(key) = Float64(PyCall.getindex(hrv_indices[key], 1))
time_vals = [getval(k) for k in time_keys]
freq_vals = [getval(k) for k in freq_keys]
poinc_vals = [getval(k) for k in poinc_keys]
geom_vals = [getval(k) for k in geom_keys]
ent_vals = [getval(k) for k in ent_keys]
p = plot(layout = (1, 2), size=(500, 500), theme=:default, framestyle=:box, grid=true)
bar!(
p[1, 1],
1:length(time_keys), time_vals;
xticks=(1:length(time_keys), time_keys),
rotation=45,
xlabel="Временной домен",
title="Временной HRV",
legend=false
)
bar!(
p[1, 2],
1:length(freq_keys), freq_vals;
xticks=(1:length(freq_keys), freq_keys),
rotation=45,
xlabel="Частотный домен",
title="Частотный HRV",
legend=false)
Выводы
В этом демо-примере был разобран способ анализа ЭКГ сигналов и предобработка радарных данных