PACS-сервер своими руками

Не так давно наша компания закончила работу над внедрением PACS-сервера (Picture Archiving and Communication System) в один из медицинских диагностических центров нашего города. До этого там стоял PACS-сервер с открытым исходным кодом — dcm4chee, который не блистал высокой скоростью работы, поскольку написан на Java. К тому же одним из требований заказчика было иметь доступ к внутренней структуре сервера. Поэтому было решено написать свой. К тому же в компании имелся опыт подобных разработок как клиентских, так и серверных частей PACS-систем, поэтому компромиссным решением было создать собственный PACS-архив, удовлетворяющий требования заказчика. Большей частью реализации ядра сервера пришлось заниматься мне и за это время был приобретён специфический опыт в этой области, чем и хочу поделиться с хабра-сообществом. Но обо всём по порядку.

Преамбула

Для общего понимания рассмотрим роль PACS-системы в диагностическом центре. В любом диагностическом центре есть диагностическое оборудование: МРТ-, КТ-томографы, УЗИ-станции или ЭКГ-аппараты (любое из этих устройств в терминах протокола DICOM называется Modality) и ПО для диагностики (у наших врачей использовался OsiriX). Получив изображения на томографе, необходимо отправить их на станцию диагностики. Очевидно, что для этого необходимо некое интегрирующее звено, которое собирает изображения с томографов, УЗИ-станций, ЭКГ-аппаратов, умеет производить по ним поиск и передавать изображения по сети. Таким звеном и являются PACS-сервера:

Очевидно, что для взаимодействия разнокачественного медицинского оборудования необходим единый протокол. И как раз таким протоколом выступает DICOM (Digital Imaging and Communications in Medicine), который за последние 20 лет был серьёзно усовершенствован, что позволило легко интегрировать медицинское оборудование в общую информационную систему. Практически все производители медицинского оборудования следуют этому протоколу. Следовательно поддержка протокола DICOM было естественным требованием к PACS-серверу. Было решено реализовать многопоточный высоконагруженный PACS, способный работать в кластере. Сервер разрабатывался на языке С++ и применялась самая адекватная на сегодняшний день библиотека для работы с протоколом DICOM, написанная на С++ — DCMTK. Именно благодаря этой библиотеке стало возможным быстро реализовывать высоконагруженные PACS-системы.

Проектируем БД

БД в PACS-системе позволяет хранить информацию о сохранённых изображениях и производить по ним поиск. Изображения также нужно уметь передавать по сети, а вместе с ним метаинформацию о изображении (кто на снимке, в какой клинике произведены, кто проводил исследование и прочее). Для этих целей протоколом DICOM предусмотрена специальная 4х уровневая модель данных, о которой можно вкратце узнать здесь. Полный список всевозможных атрибутов файла можно узнать на официальном сайте протокола[1]. В изображениях, полученных из разных аппаратов, список этих атрибутов будет отличаться, что является совершенно нормальным. Однако часть атрибутов остаётся обязательной для поддержания универсального поиска по изображениям. Их немного — всего штук десять, среди них Patient Name, Patient ID, Patient Birthday, Modality Type (КТ, МРТ, УЗИ и др), Study Date (дата исследования) и др. Помимо обязательных параметров существуют необязательные, их довольно много и поддерживать их в БД как показывает практика — является лишним.

Наличие большого количества необязательных параметров в метаинформации изображений — один из недостатков протокола DICOM. Некоторые аппараты выставляют одни параметры, некоторые — другие. Следовательно поддерживать их в БД для поиска — бессмысленно. В итоге проработав несколько вариантов, остановились на таком варианте БД:

Как видно схема БД соответствует многоуровневой структуре DICOM файлов. У одного пациента может быть много стадий (читай исследований). Исследование представляет собой множество серий, определяемых протоколом исследования. Серия хранит множество изображений.

Основные функции PACS-системы

Рассмотрим вкратце основные функции (сервисы) стандартной PACS-системы, почти все из которых я уже упомянул. Поскольку любое взаимодействие рабочих станций с PACS-системой является клиент-серверным, то и все операции также реализованы в двух вариантах — клиентском и серверном. В DCMTK присутствуют реализации обоих вариантов. PACS реализует серверную часть.
Префикс 'C-' у операций означает Composite, что подразумевает, что операция — целостная и самодостаточная и выполняется без привязки к другим операциям. Существуют ещё операции с префиксом 'N-'(N-CREATE, N-SET, N-GET и др.), которые выполняются в рамках какой-то более общей операции (выставляют статусы, информируют о начале исследования и др.). Эти операции не относятся к теме данной статьи.

C-ECHO – команда, позволяющая узнать доступность клиента в сети. Аналогична команде ping в винде. В реализации команда очень проста — нужно всего лишь отправить ответ со статусом STATUS_Success:

DIMSE_sendEchoResponse(assoc, presID, request, STATUS_Success, NULL)

где assoc – соединение, установленное клиентом, request — входящий запрос.

С-STORE — команда, позволяющая сохранять изображения на PACS-сервере в формате DCM.
Вот кусок кода, который это делает:

OFCondition storeSCP()
{
  T_DIMSE_C_StoreRQ* req = &m_msg->msg.CStoreRQ;
  DcmDataset* dset = 0x0;
  OFCondition cond = DIMSE_storeProvider(m_assoc, m_presID, req, NULL, OFTrue,  &dset,storeSCPCallback, 0x0, DIMSE_BLOCKING, 0);
  if (cond.bad())  
    Log::error("C-STORE provider failed. Text: %s", cond.text());  
  return cond;
}

void storeSCPCallback( void* /*callbackData*/,
                       T_DIMSE_StoreProgress *progress,
                       T_DIMSE_C_StoreRQ* /*request*/,
                       char * /*imageFileName*/,
                       DcmDataset **imageDataSet,
                       T_DIMSE_C_StoreRSP* response,
                       DcmDataset **statusDetail)
{
  if (progress->state == DIMSE_StoreEnd)
  {
    if ((imageDataSet != NULL) && (*imageDataSet != NULL))
    {
      DcmFileFormat dcmff(*imageDataSet);
      // some error
      if (!commandStore(&dcmff))     
        response->DimseStatus = STATUS_STORE_Refused_OutOfResources;
      
      delete *imageDataSet;
      *imageDataSet = 0x0;
    }
  }
  delete *statusDetail;
  *statusDetail = 0x0;
}

bool ServerCoreImpl::commandStore(DcmFileFormat* file)
{  
  // здесь 
  // 1. парсим файл, составляем объекты(бины) для таблиц PATIENT, STUDY, SERIES, OBJECT. 
  // 2. Если такого файла нет в базе, то сохраняем его на диск
  // 3. Если пришлось сохранять файл, то оставляем на него ссылку в БД (делаем
  //    вставку бинов)  
  // Функция возвращает true, если всё прошло удачно, иначе false
}

Колбэк storeSCPCallback срабатывает на каждый пакет, а не на каждый файл. О завершении скачивания файла свидетельствует условие progress->state == DIMSE_StoreEnd, тогда мы можем сохранить файл. Единственной сложностью при реализации этой команды является выбор структуры каталогов при сохранении файла. Чтобы не хранить в таблице OBJECTS путь к файлу, мы вычисляем его из остальных данных. Мы остановились на такой структуре каталогов: ПУТЬ_К_ХРАНИЛИЩУ/STUDY.DATE(YEAR)/STUDY.DATE(MONTH)/STUDY.DATE(DAY)/STUDY.TIME(HOUR)/PATIENT.PID(первая буква)/ PATIENT.PID/STUDY.UID/{изображения}. Такая иерархическая структура позволяет минимизировать количество вложенных папок, что позволяет работать с данной структурой каталогов без временных лагов.

Также хочется сказать, что таблица OBJECT заполняется очень интенсивно. Одно исследование на МРТ-томографе длится в среднем 20 минут, за это время томограф производит 100-300 изображений, КТ-томограф 500-700 иображений. Итого изображений за день может достигать 1440/20 * 500 = 36000 изображений за сутки. В нашем диагностическом центре перерывов в работе томографов практически нет ни днём, ни ночью. Поэтому таблица OBJECT должна хранить минимально возможное количество данных.

С-MOVE — команда, позволяющая передать изображения из PACS на рабочую или диагностическую станцию. Команда передаётся вызывающей станцией (source) на PACS и в ней указывается, на какую станцию (destination) необходимо загрузить изображения. В частном случае, если source=destination, то происходит просто скачивание файлов.

Команда C-MOVE является более универсальной по сравнению с командой С-GET, позволяющей только скачивать изображения. С-MOVE умеет скачивать изображения не только на свою, но и на любую другую. В команде указывается AETitle станции, на которую требуется загрузить изображения. AETitle – это имя клиента, обычно большими буквами (например, CLIENT_SCU). Оно устанавливается при запуске dicom-listener'а (сервера).

То есть клиент, инициализирующий команду С-MOVE на PACS-сервер, должен у себя запустить мини-PACS, позволяющий принимать только команду С-STORE. А PACS-сервер в свою очередь должен при команде С-MOVE установить новое соединение с клиентом, поднять изображения из хранилища и выполнить для каждого из низ клиентскую версию команды С-STORE обратно на клиент. Кстати, только команда С-MOVE позволяет передавать как сжатые изображения (JPEG), так и несжатые за счёт установления нового соединения.
Команда С-GET, однако, умеет загружать изображения без установления нового соединения и, следовательно, без необходимости поднимать сервер на клиентской стороне. В этом случае PACS также выполняет клиентскую версию команды С-STORE, только через соединение установленное командой С-GET.

C-FIND — команда, позволяющая производить поиск по изображениям на разных уровнях. То есть фактически существует четыре вида команды С-FIND: C-FIND на уровне PATIENT, на уровне STUDY, на уровне SERIES и на уровне IMAGE.
void HandlerFind::findSCPCallback ( /* in */
                                    void* callbackData,
                                    OFBool cancelled, 
                                    T_DIMSE_C_FindRQ* request,
                                    DcmDataset* requestIdentifiers, 
                                    int responseCount,
                                    /* out */
                                    T_DIMSE_C_FindRSP *response,
                                    DcmDataset** responseDataSet,
                                    DcmDataset** statusDetail)
{  
  // запрос отменён
  if (cancelled)
  {
    strcpy(response->AffectedSOPClassUID, request->AffectedSOPClassUID);
    response->MessageIDBeingRespondedTo = request->MessageID;
    response->DimseStatus = STATUS_FIND_Cancel_MatchingTerminatedDueToCancelRequest;
    response->DataSetType = DIMSE_DATASET_NULL;
    return;
  }
  if (responseCount == 1)
  {
     // 
     // при первом запросе инициализируем запрос к БД.
     // О критериях поиска и о уровне можно узнать в requestIdentifiers
     // 
  }
  /* 
  здесь запрашиваем очередной объект из базы и заполняем responseDataSet в соответствии с уровнем
  */
  if (/*все данные отправили*/)
  {
    strcpy(response->AffectedSOPClassUID, request->AffectedSOPClassUID);
    response->MessageIDBeingRespondedTo = request->MessageID;
    response->DimseStatus = STATUS_Success;
    response->DataSetType = DIMSE_DATASET_NULL;
    return;
  }
}

OFCondition HandlerFind::find()
{
  OFCondition cond = EC_Normal;
  T_DIMSE_C_FindRQ *req = &m_msg->msg.CFindRQ;
  FindCallbackData cdata;
  cond = DIMSE_findProvider(m_assoc, m_presID, req, findSCPCallback, &cdata, DIMSE_BLOCKING, 0);
  if (cond.bad())
    Log::loggerDicom.error("C-FIND provider failed. Text: %s", cond.text());
  return cond;
}

То есть в коллбэке нужно заполнить объекты response — параметры ответа и responseDataSet — информация о пациенте/стадии/серии/изображении, которую нужно было найти. Об отправке их обратно на клиент позаботится функция DIMSE_findProvider() из DCMTK.

Команда С-FIND опасна тем, что клиент может указать слишком общий критерий поиска и клиенту придётся отдавать большой объём информации. Например, можно запросить все стадии за последний год. Если попытаться сначала загрузить все данные на сервер, то сервер скорее всего повиснет. Поэтому делать большие запросы нельзя, нужно подгружать данные по мере срабатывания коллбэков. Для этого нужно реализовать запрос к БД в виде итератора и по мере срабатывания коллбэков вызывать next() и таким образом брать следующий объект. К тому же отменить поиск можно только по приходу коллбэка, поэтому если поиск на PACS'е повиснет на некоторое время на выборке из БД, а клиент будет вызывать отмену запроса, то никакой реакции на клиенте не произойдёт. Это актуально для поиска на уровне пациентов и стадий. Для поиска на уровне серий это неактуально, поскольку стадий, содержащих более 15 серий, мы на практике не встречали. Аналогично для поиска на уровне изображений — серий с более чем 1000 изображений мы на практике не встречали.

Обобщим

Итак, мы рассмотрели основные функции PACS-системы и её роль в общей структуре диагностического центра. Также освещены практические моменты и различные аспекты реализации медицинских промышленных PACS-систем. Однако этим функционалом PACS-системы как правило не ограничиваются. Существует также сервис WADO(Web Access to DICOM Objects) и сервис управления рабочими задачами (Modality worklist), также входящих в функции PACS-систем. Надеюсь, для кого-то статья окажется полезной и сэкономит кучу времени.

Ссылки

1. Список всех тегов DICOM (http://medical.nema.org/Dicom/2011/11_06pu.pdf, стр. 8).
2. Офицальная страница протокола DICOM – medical.nema.org/standard.html
3. Про PACS-системы на русском – ru.wikipedia.org/wiki/PACS
4. Про DICOM на русском — ru.wikipedia.org/wiki/DICOM

Комментарии 3

    0
    А как насчет Orthanc PACS? Смотрели ли Вы его? Кстати, у вас довольно не гибкая модель данных (она не полностью соответствует DICOM) В DICOM стандарте тегов очень много для каждого уровня, а потому целесообразно было-бы завести таблицу с тегами. И последний вопрос, как у Вас обстоят дела с персональными данными?
      0
      1. На счёт Orthanc PACS. Когда мы начинали разрабатывать проект, Orthanc PACS был в самых ранних версиях (0.3.0). Многих функций, которые были нужны — просто не было. А брать сырую ветку и догинать её — дело неблагодарное, лучше начать с нуля, чтобы всё контролировать всё самому.
      2. На счёт негибкой модели. Не один аппарат не поддерживает DICOM полностью. А для поиска по изображениям указанных тэгов вполне достаточно. Мы поддержали стандартное множество поисковых критериев (на основе OsiriX, станции мрт Siemens, Phillips и GE).
      3. А персональные данные пациентов относятся к уровню HIS. Это отдельная web-ориентированная подсистема.
      0
      Большое спасибо за пост! Плюсик Вам в карму, информации по работе с PACS и DICOM на русском языке крайне мало.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое