Основы DICOM с использованием .NET и C#: операции запроса и извлечения (C-MOVE)

Введение

В материале "Основы 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, а также того, когда и где имеет смысл использовать/развертывать эти две операции в полевых условиях.

 

Scroll to top