Введение
В материале "Основы DICOM с использованием .NET и C# — операции запроса и извлечения (C-FIND)" по операциям запроса и извлечения DICOM мы рассмотрели, как найти данные на удаленном сервере. Сейчас мы продолжим изучение операций запроса/извлечения, рассмотрев первую из двух операций DICOM, которые помогают нам получить эти данные. Я расскажу об операции C-MOVE, а о C-GET — в следующем уроке. Мы создадим клиент DICOM C-MOVE SCU, который может дать указание удаленному серверу DICOM отправить ранее запрошенные данные на указанный нами АЕТ.
Прежде чем мы начнем…
Как и в предыдущих примерах программирования, я буду использовать минимальный код и подход, чтобы проиллюстрировать концепции, которые раскрываются в этом руководстве. Это означает, что код, который я привожу здесь, подходит для демонстрации концепции, но не является наиболее эффективным кодом для развертывания в реальной жизни и в вашем рабочем приложении.
Чтобы начать работу, вам нужно будет настроить несколько вещей на вашем компьютере, включая среду разработки Visual Studio, а также набор инструментов fo-dicom, прежде чем вы сможете запустить приведенный пример, если вы хотите попробовать это самостоятельно.
Кроме того, вам также понадобится сервер DICOM для выполнения некоторых операций, описанных в этом руководстве. Для примеров, показанных ниже, я буду использовать сервер dcm4chee-arc, который установлен и работает в виртуалбоксе на моем компьютере. В Интернете доступно множество других серверов PACS с открытым исходным кодом. Однако мне нравится использовать dcm4chee-arc, поскольку его очень легко установить и начать работу. Убедитесь, что у вас уже загружены некоторые исследования и вы можете искать исследования пациентов в этих данных. Кроме того, нам понадобятся не только этот dicom-сервер, но и ещё одно dicom-устройство, которое может выполнять роль SCP, т.е. принимать dicom-объекты. Это может быть как ещё один аналогичный сервер, так и программа-вьювер (тот же Радиант, например), которая имеет сервис приема файлов. У меня это будет моя программа, которая принимает файлы и сохраняет их в собственной базе данных для дальнейшего локального просмотра. Из ниже изложенного станет понятно почему такие требования.
Этот материал, как и другие материалы этой серии, основан на моем переводе и адаптации материалов с сайта https://saravanansubramanian.com/. Так как на авторском сайте нет статьи с примером иллюстрирующем C-MOVE для C#, то за теоретическую основу была взята часть статьи "DICOM Basics using Java - Query and Retrieve Operations (C-MOVE)", а иллюстрации кода были разработаны мною (прим. - А.К.)
Получение данных DICOM с помощью C-MOVE
В материале "Основы DICOM с использованием .NET и C# — операции запроса и извлечения (C-FIND)" по операциям запросов в DICOM мы отмечали, что служба C-FIND помогает находить объекты DICOM. Однако, после того, как вы найдете то, что ищете, вам нужно это получить. Существует две составные операции DICOM, которые позволяют это сделать: C-MOVE и C-GET. Из этих двух операций C-MOVE исторически является наиболее популярным методом извлечения файлов DICOM в клинических условиях.
Людям, впервые работающим с DICOM, название этой операции покажется немного нелогичным, поскольку требуется получить данные, а не переместить. Однако название имеет смысл, поскольку на самом деле эта операция может служить двум целям. Он может не только извлекать данные, но также указать удаленному узлу DICOM отправить данные в совершенно другое место назначения, чего C-GET сделать не может. Я расскажу о различиях между этими двумя операциями в руководстве по составной операции C-GET. Следует отметить, что этим действиям «за кулисами» помогает совершенно другая составная операция под названием C-STORE, о которой будет рассказано в другой статье этой серии. За кулисами, когда C-MOVE SCP получает запрос, он фактически инициирует совершенно новую ассоциацию DICOM в качестве C-STORE SCU с конечным пунктом назначения и отправляет туда данные. Конечным пунктом назначения («C-STORE-SCP») в большинстве случаев является C-MOVE SCU, который инициировал операцию, но имейте в виду, что вы можете указать операции сохранения для передачи данных в другой пункт назначения, как я упоминал ранее.
Операция C-STORE, которая происходит скрыто, в DICOM называется «дополнительной операцией» ("sub-operation"). Статус этой подоперации передается в C-MOVE SCU, чтобы держать его в курсе общего прогресса. Эта операция C-STORE выполняется над совершенно другой DICOM-ассоциацией со всеми необходимыми согласованиями ассоциации, которые должны произойти, чтобы гарантировать, что абстрактный синтаксис и синтаксисы передачи приемлемы для получателя. По логике вещей, чтобы вся операция прошла успешно, в пункте назначения должна быть запущена активная служба C-STORE SCP. Надеюсь, что диаграмма ниже отражает суть того, что на самом деле происходит во время операции C-MOVE. Следует иметь в виду, что C-STORE SCU и C-STORE SCP, которые взаимодействуют за кулисами, должны знать друг друга заранее (конфигурации DICOM AE должны быть установлены с обеих сторон для обеспечения безопасности DICOM). В первую очередь это связано с тем, что в DICOM нет «динамической аутентификации», как это наблюдается в других протоколах связи, таких как HTTP, и любые устройства, которые взаимодействуют друг с другом с использованием протокола DICOM, должны быть настроены на обеих сторонах, чтобы знать друг о друге.
С точки зрения связи DICOM, от клиента поставщику услуг (C-MOVE SCP) передается команда DIMSE («C-MOVE-RQ») в сочетании с объектом IOD DICOM, содержащим критерии для данных, которые необходимо переместить в указанное место назначения (C-MOVE-SCU). Критерии задаются с помощью атрибута «соответствующие ключи», а также с помощью другого атрибута, называемого уровнем запроса/извлечения (query/retrieve level), который определяет, что мы хотим переместить (всё исследование, серию, изображение и т. д.). Иногда также может быть указан набор символов для управления кодировкой символов представлений значений возвращаемых данных DICOM. Затем C-MOVE SCP переключает роли на C-STORE SCU и отправляет команду C-STORE-RQ и данные, соответствующие критериям, в указанное место назначения. C-MOVE SCP продолжает сообщать о состоянии операции C-STORE, например о количестве переданных файлов, количестве файлов, ожидающих передачи, любых предупреждениях и ошибках, возникающих во время операции и т. д. C-MOVE SCU может инициировать операцию отмены в любое время (с помощью команды C-CANCEL-MOVE-REQ), при этом все текущие операции C-STORE будут отменены, а статус «отменен» будет передан обратно на C-MOVE SCU. Структуры запроса и ответа C-MOVE показаны ниже для справки. Более подробную информацию можно найти в официальной документации DICOM, поскольку это практически вся теория, которую я могу осветить в этой статье.
Пример операции C-MOVE: отправка полученных данных в указанный пункт назначения
Давайте рассмотрим краткий пример использования набора инструментов fo-dicom для выполнения операции C-MOVE SCU. Я собираюсь подключиться к серверу dcm-4chee-arc, работающему в virtualbox на моем компьютере. Я настроил его для прослушивания соединений через порт 11112 с IP 192.168.88.26. На этом сервере также есть заголовки AE, настроенные как для клиента C-FIND SCU, так и для C-STORE SCP, на который ему необходимо возвращать данные. АЕТ самого сервера - XRAYARCHIVE. АЕТ программы, которая будет получать данные - MOVEAET. Наша программа, выполняющая роль C-MOVE SCU будет иметь АЕТ - LOCALAET. Устройство, куда мы направляем данные с сервера - в нашем случае - программа с MOVEAET, должно быть известно серверу, т.е. для него должны быть указаны также адрес и порт. Если нам нужно вернуть данные непосредственно в программу, которая инициирует запрос C-MOVE, то ее АЕТ должен быть также указан на сервере - LOCALAET в нашем случае.
Вот так это выглядит на моем сервере:
Опять же, как я упоминал ранее, убедитесь, что у вас уже загружены на сервер некоторые исследования и вы можете искать исследования пациентов в этих данных. У меня для иллюстрации кода были загружены данные для пациента Anonymous - одно исследование, в котором три серии, а в каждой серии по одному изображению.
В примере, показанном ниже, я собираюсь отправить запрос C-FIND, чтобы найти сначала все исследования, принадлежащие конкретному пациенту, затем в каждом из исследований (здесь оно будет одно только) найти серии с определенным описанием (тег Series Description) "KNEE LEFT*" - также всего одна серия, а затем уже создать запрос C-MOVE для найденных серий и отправить их в мою программу с AET MOVEAET, которая работает на компьютере с адресом 192.168.88.245 и прослушивает порт 5678. Код должен быть понятен, если вы понимаете теорию этой операции, которая была объяснена в предыдущих разделах. Обратите внимание: в запросе мы не указываем адрес и порт устройства, на которое сервер должен отправить данные. Указывается только АЕТ, а сервер уже "знает", на какой адрес и порт их отправлять.
Код разделил на куски для лучшей читабельности.
Итак в процедуре Main задаем вышеизложенную логику.
try { if (await GetDicomStudyUidsAsync(patientName)) // получаем список УИДов исследований для пациента patientName { foreach (string studyUID in studyUIDs) // перебираем список УИДов исследований { if (await GetDicomSeriesUidsAsync(studyUID)) // получаем список серий в исследовании { foreach (string seriesUID in seriesUIDs) // перебираем список серий в каждом исследовании { MoveSeriesUidsAsync(studyUID, seriesUID); //отправляем запрос на отправку серии с сервера на удаленное устройство } } else { LogToDebugConsole("No series found"); } } } else { LogToDebugConsole("No studies found"); } Console.ReadLine(); } catch (Exception e) { LogToDebugConsole($"Error request -> {e.StackTrace}"); }
Процедура получения списка исследований для пациента с именем "Anon*": здесь * - подстановочный знак, заменяет любое количество любых символов.
private static async Task GetDicomStudyUidsAsync(string patientNameForSearch) { try { // Создаем клиент для установления ассоциации var dicomClient = DicomClientFactory.Create(QRServerHost, QRServerPort, false, localAET, QRServerAET); dicomClient.NegotiateAsyncOps(); //Добавляем обработчик, который будет получать уведомления о любых отклонениях ассоциации. dicomClient.AssociationRejected += ClientOnAssociationRejected; //Добавляем обработчик для получения уведомлений о любой информации об ассоциации при успешных соединениях. dicomClient.AssociationAccepted += ClientOnAssociationAccepted; //Добавляем обработчик для уведомления об успешном освобождении ассоциации — это также может быть вызвано удаленным узлом. dicomClient.AssociationReleased += ClientOnAssociationReleased; // Создание нового экземпляра запроса уровня "Study Root" var studyRequest = new DicomCFindRequest(DicomQueryRetrieveLevel.Study); studyRequest.Dataset.AddOrUpdate(DicomTag.SpecificCharacterSet, "ISO_IR 192"); // Фильтр по имени пациента studyRequest.Dataset.AddOrUpdate(DicomTag.PatientName, patientNameForSearch); // Запрос на возврат тега StudyInstanceUID studyRequest.Dataset.AddOrUpdate(DicomTag.StudyInstanceUID, string.Empty); // Найденные идентификаторы исследований добавляем в список studyRequest.OnResponseReceived += (req, response) => { if (response.Status.ToString() != "Success") {// Пока сервер не вернет "Success" добавляем в список найденные идентификаторы исследований studyUIDs.Add(response.Dataset.GetSingleValueOrDefault(DicomTag.StudyInstanceUID, "")); LogToDebugConsole($"Found study: {response.Dataset.GetSingleValueOrDefault(DicomTag.StudyInstanceUID, "")}"); } }; // Добавляем запрос на поиск исследований пациента await dicomClient.AddRequestAsync(studyRequest); await dicomClient.SendAsync(); // Собственно посылка запроса if (studyUIDs.Count > 0) return true; else return false; } catch (Exception e) { LogToDebugConsole($"Error get study list: {e.StackTrace}"); return false; } }
Процедура получения списка серий. Все аналогично запросу исследований. Только уровень запроса Dicom Query/Retrieve Level другой - "Series Root". Также обратите внимание, что в фильтре для поиска заданы два критерия - УИД исследования и описание серии seriesDescription="KNEE LEFT*".
private static async Task GetDicomSeriesUidsAsync(string studyUIDForSearch) { try { // Создаем клиент для установления ассоциации var dicomClient = DicomClientFactory.Create(QRServerHost, QRServerPort, false, localAET, QRServerAET); dicomClient.NegotiateAsyncOps(); dicomClient.AssociationRejected += ClientOnAssociationRejected; dicomClient.AssociationAccepted += ClientOnAssociationAccepted; dicomClient.AssociationReleased += ClientOnAssociationReleased; // Создание нового экземпляра запроса уровня "Series Root" var seriesRequest = new DicomCFindRequest(DicomQueryRetrieveLevel.Series); seriesRequest.Dataset.AddOrUpdate(DicomTag.SpecificCharacterSet, "ISO_IR 192"); // Фильтр по UID исследования и описанию серии seriesRequest.Dataset.AddOrUpdate(DicomTag.StudyInstanceUID, studyUIDForSearch); seriesRequest.Dataset.AddOrUpdate(DicomTag.SeriesDescription, seriesDescription); // Запрос на возврат тега SeriesInstanceUID seriesRequest.Dataset.AddOrUpdate(DicomTag.SeriesInstanceUID, string.Empty); // Найденные идентификаторы исследований добавляем в список seriesRequest.OnResponseReceived += (req, response) => { if (response.Status.ToString() != "Success") { seriesUIDs.Add(response.Dataset.GetSingleValueOrDefault(DicomTag.SeriesInstanceUID, "")); LogToDebugConsole($"Found series: {response.Dataset.GetSingleValueOrDefault(DicomTag.SeriesInstanceUID, "")}"); } }; // Добавляем запрос на поиск ctcthbqhbq пациента await dicomClient.AddRequestAsync(seriesRequest); await dicomClient.SendAsync(); // Собственно посылка запроса с ожиданием результатов if (seriesUIDs.Count > 0) return true; else return false; } catch (Exception e) { LogToDebugConsole($"Error get series list: {e.StackTrace}"); return false; } }
Процедура запроса C-MOVE для найденных серий
private static async void MoveSeriesUidsAsync(string studyUid, string seriesUid) { try { // Создаем клиент для установления ассоциации var dicomClient = DicomClientFactory.Create(QRServerHost, QRServerPort, false, localAET, QRServerAET); dicomClient.NegotiateAsyncOps(); dicomClient.AssociationRejected += ClientOnAssociationRejected; dicomClient.AssociationAccepted += ClientOnAssociationAccepted; dicomClient.AssociationReleased += ClientOnAssociationReleased; // Создание запроса C-MOVE var cmove = new DicomCMoveRequest(moveAET, studyUid, seriesUid); cmove.OnResponseReceived += (DicomCMoveRequest request, DicomCMoveResponse response) => { if (response.Status.ToString() == "Success") { //string studyUID = request.Dataset?.GetSingleValueOrDefault(DicomTag.StudyInstanceUID, ""); string seriesUID = request.Dataset?.GetSingleValueOrDefault(DicomTag.SeriesInstanceUID, ""); LogToDebugConsole($"Move series SUCCESS -------------------------------->>>> {seriesUID}"); } else { LogToDebugConsole($"Move series {seriesUid} - {response.Status}"); } }; // Добавляем запрос на поиск ctcthbqhbq пациента await dicomClient.AddRequestAsync(cmove); await dicomClient.SendAsync(); // Собственно посылка запроса с ожиданием результатов } catch (Exception e) { LogToDebugConsole($"Error move series: {e.StackTrace}"); } }
Библиотека fo-dicom позволяет нам создать такой запрос всего одной строчкой - var cmove = new DicomCMoveRequest(moveAET, studyUid, seriesUid)
Причем конструктор DicomCMoveRequest имеет три перегрузки: можно было как углубиться ещё на один уровень - выбрать определенное изображение из серии, так и вообще-то остановиться на уровне исследований. Другими словами можно создавать запросы на получение/пересылку всего исследования, всей серии и конкретного изображения.
Но первым аргументом обязательно указывается АЕТ узла DICOM пункта назначения.
Результат выполнения программы для приведенного примера:
Заключение
На этом завершается статья о том, как операции запроса и извлечения выполняются составной службой C-MOVE в DICOM. Эта операция используется для получения данных DICOM в C-MOVE SCU или может использоваться для отправки данных в совершенно новое место назначения. В следующем материале этой серии, посвященной стандарту DICOM, мы рассмотрим другой составной сервисе под названием C-GET , который также обеспечивает получение найденных данных. Также коснемся различий между C-GET и C-MOVE, а также того, когда и где имеет смысл использовать/развертывать эти две операции в полевых условиях.