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

Ниже я приведу несколько иллюстраций.

Вид начала формата EDF в HEX-редакторе
Вид начала формата EDF в HEX-редакторе
Окно импорта формата EDF в EDFbrowser
Окно импорта формата EDF в EDFbrowser
Вид кардиограммы «2024-10-07_11-46-23» в EDFbrowser
Вид кардиограммы «2024-10-07_11-46-23» в EDFbrowser
Ещё одна кардиограмма
Ещё одна кардиограмма
Ещё одна кардиограмма
Ещё одна кардиограмма

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

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