В предыдущей статье я разобрался с устройством формата файлов 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 непосредственно в них.
