В предыдущей статье я разобрался с устройством формата файлов ECG кардиограммы цифрового портативного кардиографа «Кардиан-ПМ». Это было сделано с целью получения доступа к информации о кардиограмме с помощью какого-либо другого ПО. Как минимум, в качестве такого ПО можно использовать Excel, подготавливая для него файлы в формате CSV. Но хотелось использовать более специализированное и распространённое ПО для работы именно с кардиограммами. Идея пришла использовать формат EDF – популярный формат временных рядов с открытой спецификацией. Как оказалось, этот формат используют на практике также для хранения кардиограмм. Одна из популярных программ, где можно открыть этот формат, – EDFbrowser. Именно с этой программой я и предпочёл работать. Большинство программ по работе с кардиограммами, находящиеся в открытом доступе, должны поддерживать этот популярный формат данных.
На сайте edfplus.info, а точнее, вот по этой ссылке я нашёл спецификацию EDF формата. У него большой текстовый заголовок с множеством отступов в виде пробелов. А формат представления самих данных кардиограммы не отличается от формата представления в исходном ECG файле. Единственное отличие – деление каналов на сегменты и их своеобразная группировка. В заголовке также содержится ФИО пациента, но это поле должно быть записано латинскими буквами. По крайней мере, EDFbrowser отказался читать файл, в котором ФИО записано кириллицей. Поэтому в программу пришлось добавить функцию транслитерации.
Программу я писал в Dev-Cpp на языке Си. Писал не с нуля, а использовал свои же более ранние наработки и шаблоны для работы с множеством файлов в каталоге, чтения из файла и записи в файл. Можно при желании перенести код в более современную среду разработки. Я не стал создавать структуры с описанием заголовков входного и выходного файлов. Но чтобы не нагромождать код с чтением и заполнением заголовка, я его вывел в отдельный файл header.cpp, который прикрепил с помощью «include» к основному файлу с программой ecg2edf.cpp. Программа работает в командной строке с множеством файлов ECG, которые лежат в одноимённой папке, находящейся в папке с программой. Результат работы программы – формирование выходных файлов EDF с такими же именами в соответствующей новой папке. Программа в моём примере обработала сотню файлов практически мгновенно. Код программы со встроенными комментариями я приведу ниже.
ecg2edf.cpp
#include <stdio.h>
#include <windows.h>
#include <string.h>
#define NUMCHANNELS 12 //Число каналов.
#define NUMSAMPLES 500 //Число сэмплов в сегменте.
#define MAXSMP 32767 //Цифровой минимум каналов (в мВ).
#define MINSMP -32767 //Цифровой минимум каналов (в мВ).
#define NUMSEGM 10 //Число сегментов (можно было 1, но я выбрал 10 по 1 сек.).
#define TIMESEC 1 //Длительность 1 канала в 1 сегменте (сек.).
#define PHYSMIN -6 //Физический минимум каналов (в мВ).
#define PHYSMAX 6 //Физический максимум каналов (в мВ).
DWORD wr;
DWORD ww;
char* transl[32]={"A","B","V","G","D","E","ZH","Z","I",
"Y","K","L","M","N","O","P","R","S","T",
"U","F","H","TS","CH","SH","SHCH","'","Y","*",
"E","YU","YA"}; //Замена символов на фрагменты строк для транслитерации.
HANDLE openInputFile(const char * filename) {
return CreateFile ( filename, // Open Two.txt.
GENERIC_READ, // Open for writing
0, // Do not share
NULL, // No security
OPEN_ALWAYS, // Open or create
FILE_ATTRIBUTE_NORMAL, // Normal file
NULL); // No template file
}
HANDLE openOutputFile(const char * filename) {
return CreateFile ( filename, // Open Two.txt.
GENERIC_WRITE, // Open for writing
0, // Do not share
NULL, // No security
OPEN_ALWAYS, // Open or create
FILE_ATTRIBUTE_NORMAL, // Normal file
NULL); // No template file
}
//Установка смещения в файле.
void filepos(HANDLE f, unsigned long int p){
SetFilePointer (f, p, NULL, FILE_BEGIN);
}
//Чтение 32 бит в little-endian.
unsigned long int read32(HANDLE f){
unsigned long int b0;
ReadFile(f, &b0, 4, &wr, NULL);
return b0;
}
//Чтение 16 бит в little-endian.
signed short int read16(HANDLE f){
signed short int b0;
ReadFile(f, &b0, 2, &wr, NULL);
return b0;
}
//Чтение 16 бит в big-endian.
signed short int b_read16(HANDLE f){
unsigned char b0,b1;
signed short int b;
ReadFile(f, &b1, 1, &wr, NULL);
ReadFile(f, &b0, 1, &wr, NULL);
b=((signed short int)b1<<8)|b0;
return b;
}
//Чтение 8 бит в little-endian.
unsigned char read8(HANDLE f){
unsigned char b0;
ReadFile(f, &b0, 1, &wr, NULL);
return b0;
}
//Запись 32-битного целого числа в little-endian.
void write32(HANDLE f, signed long int a){
WriteFile(f, &a, 4, &ww, NULL);
}
//Запись 16-битного целого числа в little-endian.
void write16(HANDLE f, signed short int a){
WriteFile(f, &a, 2, &ww, NULL);
}
//Запись строки.
void write_str(HANDLE f, char *str){
WriteFile(f, str, strlen(str), &ww, NULL);
}
int main(){
char fullname[100];
char fullname1[100];
char name[32];
char sf[100];
char ts[2];
unsigned char pcnrus[70];
char pcn[70];
char dob[12];
char buf[1000];
signed short int dd,mn,yyyy,hh,mm,ss; //Переменные для даты и времени.
unsigned char cz=0;
signed short int iii[500],avl[500],avr[500],avf[500],wct[500],v[500];
unsigned int s;
unsigned char ch,i,sg;
signed short int smp1,smp2,smp3;
WIN32_FIND_DATA fld;
HANDLE hf;
HANDLE in,out;
hf=FindFirstFile(".\\ECG\\*.ECG",&fld); //Поиск всех файлов ECG.
do{ //Цикл по всем файлам в папке.
sprintf(name,"%s",fld.cFileName);
sprintf(fullname,".\\ECG\\%s",fld.cFileName);
printf("%s\n",fullname);
name[strlen(name)-4]=0; //Обрезка строки до расширения.
sprintf(fullname1,".\\EDF\\%s.edf",name); //Новое расширение edf с тем же именем файла в новой папке.
printf("%s\n",fullname1);
in=openInputFile(fullname); //Открытие файла на чтение.
out=openOutputFile(fullname1); //Открытие файла на запись.
#include "header.cpp" //Формирование заголовка в отдельном файле вложения.
for(sg=0;sg<NUMSEGM;sg++){ //Пробег по сегментам.
filepos(in,200+10000*0+1000*sg); //Точечное позиционирование на группы байтов для канала 0 (отведение I).
ReadFile(in, buf, 1000, &wr, NULL); //Чтение фрагмента на 500 16-битных сэмплов канала 0.
WriteFile(out, buf, 1000, &ww, NULL); //Запись фрагмента канала 0 в выходной файл;
filepos(in,200+10000*7+1000*sg); //Всё то же самое для канала 7 (отведение II).
ReadFile(in, buf, 1000, &wr, NULL);
WriteFile(out, buf, 1000, &ww, NULL);
for(s=0;s<NUMSAMPLES;s++){ //Посэмловое вычисление отведений III, aVL, aVR, aVF и вспомогательного WCT.
filepos(in,200+10000*0+1000*sg+(2*s)); //Позиционирование на сэмплы канала 0 (отведения I).
smp1=read16(in); //Чтение сэмплов канала 0 (отведения I).
filepos(in,200+10000*7+1000*sg+(2*s)); //Позиционирование на сэмплы канала 7 (отведения II).
smp2=read16(in); //Чтение сэмплов канала 7 (отведения II).
smp3=smp2-smp1; //Вычисление сэмплов отведения III.
iii[s]=smp3; //Запись в буфер сэмплов отведения III.
avl[s]=(smp1-smp3)/2; //Вычисление и запись в буфер фрагмента отведения aVL.
avr[s]=-(smp1+smp2)/2; //Вычисление и запись в буфер фрагмента отведения aVR.
avf[s]=(smp2+smp3)/2; //Вычисление и запись в буфер фрагмента отведения aVF.
wct[s]=(smp1+smp2)/3; //Вычисление и запись в буфер фрагмента сигнала WCT.
}
WriteFile(out, iii, 1000, &ww, NULL); //Запись буфера фрагмента отведения III в выходной файл.
WriteFile(out, avl, 1000, &ww, NULL); //Запись буфера фрагмента отведения aVL в выходной файл.
WriteFile(out, avr, 1000, &ww, NULL); //Запись буфера фрагмента отведения aVR в выходной файл.
WriteFile(out, avf, 1000, &ww, NULL); //Запись буфера фрагмента отведения aVF в выходной файл.
for(ch=6;ch>0;ch--){ //Пробег по каналам грудных отведений (задом наперёд по факту расположения).
for(s=0;s<NUMSAMPLES;s++){ //Посэмпловое чтение фрагмента канала текущего грудного отведения.
filepos(in,200+10000*ch+1000*sg+(2*s));
v[s]=read16(in); //Чтение сэмплов во временный буфер.
v[s]-=wct[s]; //Корректировка сэмплов грудных отведений на сэмпл WCT.
}
WriteFile(out, v, 1000, &ww, NULL); //Запись временного буфера в выходной файл фрагмента канала текущего грудного отведения.
}
}
CloseHandle(in);
CloseHandle(out);
printf("\n");
}while(FindNextFile(hf,&fld)); //Пока не кончатся файлы.
//}while(0);
system("PAUSE");
return 0;
}header.cpp
sprintf(sf,"%-8d",0); //Запись текста с нулём (версия формата) и пробелами в буферную строку.
write_str(out,sf); //Запись буферной строки в выходной файл.
filepos(in,17); //Позиционирование на поле ФИО пациента во входном файле.
ReadFile(in, pcnrus, 45, &wr, NULL); //Чтение строки ФИО пациента.
i=0; //Подготовка к преобразованию текста в транслит.
sprintf(pcn,"%s",""); //Инициализация строки с транслитом ФИО и датой рождения.
while(pcnrus[i]){ //Транслит текста посимвольно.
if(pcnrus[i]<=127){ //Половина ASCII переписывается, как есть.
sprintf(ts,"%c",pcnrus[i]);
strcat(pcn,ts); //Добавление трансл. символов.
}
if(pcnrus[i]>=128&&pcnrus[i]<=191){ //Символы до начала русского алфавита заменяются знаком вопроса.
strcat(pcn,"?");
}
if(pcnrus[i]>=192&&pcnrus[i]<=223){ //Русские заглавные буквы заменяются на транстлит из массива.
strcat(pcn,transl[pcnrus[i]-192]);
}
if(pcnrus[i]>=224){ //Русские строчные буквы заменяются на транстлит из того же массива.
strcat(pcn,transl[pcnrus[i]-224]);
}
i+=1;
}
filepos(in,62);
ReadFile(in, dob, 11, &wr, NULL); //Чтение даты рождения пациента.
strcat(pcn," (dob: ");
strcat(pcn,dob); //Добавление даты рождения в буфер.
strcat(pcn,")");
printf("%s\n",pcn);
sprintf(sf,"%-80s",pcn); //Запись буфера с лишними пробелами (согласно формату) в буферную строку.
write_str(out,sf); //Запись буферной строки в выходной файл.
sprintf(sf,"%-80s","Program_of_R3EQ_01.11.2025"); //Идентификатор записи.
write_str(out,sf);
filepos(in,138); //Дата и время в числовом формате.
ss=b_read16(in);
mm=b_read16(in);
hh=b_read16(in);
dd=b_read16(in);
mn=b_read16(in);
yyyy=b_read16(in);
sprintf(sf,"%02i.%02i.%02i",dd,mn,yyyy-2000);
write_str(out,sf);
sprintf(sf,"%02i.%02i.%02i",hh,mm,ss);
write_str(out,sf);
sprintf(sf,"%-8d",(NUMCHANNELS+1)*256); //Размер заголовка.
write_str(out,sf);
sprintf(sf,"%-44s"," "); //Резерв с пробелами.
write_str(out,sf);
sprintf(sf,"%-8d",NUMSEGM); //Число сегментов в записи.
write_str(out,sf);
sprintf(sf,"%-8d",TIMESEC); //Длительность 1 канала в 1 сегменте (сек.).
write_str(out,sf);
sprintf(sf,"%-4d",NUMCHANNELS); //Число каналов.
write_str(out,sf);
sprintf(sf,"%-16s","I"); //Название канала 1.
write_str(out,sf);
sprintf(sf,"%-16s","II"); //Название канала 2.
write_str(out,sf);
sprintf(sf,"%-16s","III"); //Название канала 3.
write_str(out,sf);
sprintf(sf,"%-16s","aVL"); //Название канала 4.
write_str(out,sf);
sprintf(sf,"%-16s","aVR"); //Название канала 5.
write_str(out,sf);
sprintf(sf,"%-16s","aVF"); //Название канала 6.
write_str(out,sf);
sprintf(sf,"%-16s","V1"); //Название канала 7.
write_str(out,sf);
sprintf(sf,"%-16s","V2"); //Название канала 8.
write_str(out,sf);
sprintf(sf,"%-16s","V3"); //Название канала 9.
write_str(out,sf);
sprintf(sf,"%-16s","V4"); //Название канала 10.
write_str(out,sf);
sprintf(sf,"%-16s","V5"); //Название канала 11.
write_str(out,sf);
sprintf(sf,"%-16s","V6"); //Название канала 12.
write_str(out,sf);
for(i=0;i<NUMCHANNELS;i++){ //Контекст каналов (одинаковый на все).
sprintf(sf,"%-80s","ECG Electrode");
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Единица измерения каналов (одинаковая на все).
sprintf(sf,"%-8s","mV");
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Физический минимум каналов (одинаковый на все).
sprintf(sf,"%-8d",PHYSMIN);
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Физический максимум каналов (одинаковый на все).
sprintf(sf,"%-8d",PHYSMAX);
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Цифровой минимум каналов (одинаковый на все).
sprintf(sf,"%-8d",MINSMP);
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Цифровой максимум каналов (одинаковый на все).
sprintf(sf,"%-8d",MAXSMP);
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Параметры фильтра (пустота).
sprintf(sf,"%-80s"," ");
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Число сэмплов в сегменте (одинаковое на все каналы).
sprintf(sf,"%-8d",NUMSAMPLES);
write_str(out,sf);
}
for(i=0;i<NUMCHANNELS;i++){ //Резервное поле каналов (путсота).
sprintf(sf,"%-32s"," ");
write_str(out,sf);
}Сёрфинг формата EDF в EDFbrowser мне показался не очень удобным, а в других программах я не пробовал. Особо глубоко я не ковырялся в настройках и возможностях EDFbrowser. Моя задача была убедиться, что всё работает, и кардиограмма в EDFbrowser отображается также, как и в Cardian-PM на Android. Хотя, и там тоже не очень удобное отображение на ограниченном экране. А кардиограмма «2024-10-07_11-46-23», взятая для примера для этой статьи, оказалась не очень удачной сама по себе: в физических каналах для I и II отведений присутствует сильная помеха 50 Гц, которая повлияла на отображение остальных отведений. Для таких случаев нужно применять фильтр, если программы для отображения позволяют это делать. В Cardian-PM, например, такая функция присутствует. Также я уделил особое внимание вертикальному масштабу кардиограммы, правильно согласовав шкалы амплитуды двух программ. Это согласование учитывается в заголовке формата EDF.
Ниже я приведу несколько иллюстраций.





Как видно, при определённых манипуляциях с масштабом отображения можно добиться более-менее внятного вида, но наложения соседних каналов могут быть неизбежны. Впрочем, такие наложения наблюдаются даже на термопечати из Cardian-PM.
На этом можно завершить тему про данный формат кардиограмм. При желании можно изучить другие, более внятные и популярные форматы, которые более широко распространены, и преобразовывать ECG непосредственно в них.
