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

Введение

В этом материале мы продолжим изучение того, как в DICOM реализуются операции запросов извлечения данных. Мы уже рассмотрели две другие составные операции в этой группе. А именно, это операции C-FIND и C-MOVE . C-FIND, как вы помните, позволяет запрашивать информацию о данных DICOM (изображения, структурированные отчеты и т. д.), а операция C-MOVE позволяет затем получить эти данные (а также переслать эти данные в указанное место назначения). Теперь мы рассмотрим еще один способ получения данных DICOM с использованием операции с более подходящим названием, называемой составной операцией C-GET, которая работает немного иначе, чем операция C-MOVE. C-GET не так популярен, как C-MOVE, но, тем не менее, очень полезен. Большинству пользователей, не имеющих опыта работы в сфере здравоохранения, также будет гораздо проще с ним справиться, поскольку он работает во многом так же, как запрос GET работает при использовании протокола HTTP.

Прежде чем мы начнем…

Как и в предыдущих примерах программирования, я буду использовать минимальный код и подход, чтобы проиллюстрировать концепции, которые раскрываются в этом руководстве. Это означает, что код, который я привожу здесь,  подходит для демонстрации концепции, но не является наиболее эффективным кодом для развертывания в реальной жизни и в вашем рабочем приложении.

Чтобы начать работу, вам нужно будет настроить несколько вещей на вашем компьютере, включая среду разработки Visual Studio, а также набор инструментов fo-dicom, прежде чем вы сможете запустить приведенный пример, если вы хотите попробовать это самостоятельно.

Кроме того, вам также понадобится сервер DICOM для выполнения некоторых операций, описанных в этом руководстве. Для примеров, показанных ниже, я буду использовать сервер dcm4chee-arc, который установлен и работает в виртуалбоксе на моем компьютере. В Интернете доступно множество других серверов PACS с открытым исходным кодом. Однако мне нравится использовать dcm4chee-arc, поскольку его очень легко установить и начать работу. Убедитесь, что у вас уже загружены некоторые исследования и вы можете искать исследования пациентов в этих данных.

Этот материал, как и другие материалы этой серии, основан на моем переводе и адаптации материалов с сайта https://saravanansubramanian.com/. Так как на авторском сайте нет статьи с примером иллюстрирующем C-GET для C#, то за теоретическую основу была взята часть статьи "DICOM Basics using Java - Query and Retrieve Operations (C-GET)", а иллюстрации кода были разработаны мною (прим. - А.К.)

Получение данных DICOM с помощью C-GET

C-GET — это еще один (и более «современный») способ получения данных DICOM, который может вас заинтересовать. В отличие от операции C-MOVE (со слегка нелогичным названием), которая позволяет не только извлекать данные, но и отправлять данные в другой пункт назначения, операция C-GET делает именно то, что должна, а именно - получить интересующие вас данные DICOM. Она использует одну ассоциацию DICOM (вместо двух, как C-MOVE) для выполнения операции извлечения данных, что упрощает использование с точки зрения конфигурации, поскольку требуется меньше портов.

Несмотря на более интуитивное название и даже несмотря на то, что данные могут быть отправлены обратно вызывающему абоненту с использованием той же ассоциации DICOM, может быть немного удивительно, что операция C-MOVE по-прежнему остается более популярным методом, используемым для извлечения данных. Для этого есть прежде всего две причины. Первая причина заключается в том, что C-MOVE будет отправлять данные только вызывающим абонентам, которые уже зарегистрированы в таблицах поиска AE, поэтому администраторы PACS обычно более доверяют этому подходу. Вторая причина популярности C-MOVE заключается в том, что его проще реализовать, чем C-GET, который требует гораздо большего количества согласований и переключения ролей между одними и теми же устройствами, поскольку все эти действия должны происходить через одно соединение. По этим причинам огромное количество производителей PACS исторически не поддерживали работу C-GET. Тем не менее C-GET имеет ряд применений в диагностической визуализации, например, при получении изображений через Интернет из системы PACS, развернутой в больничной системе (что C-MOVE может сделать лишь с большими трудностями, поскольку нужны дополнительно настроенные порты и заголовки AE), а также используется в операциях рабочего списка модальности DICOM. Надеюсь, что диаграмма ниже должна проиллюстрировать, как работает операция C-GET.

С точки зрения связи DICOM, от клиента поставщику услуг (C-GET SCP) передается команда DIMSE («C-GET-RQ») в сочетании с объектом DICOM IOD, содержащим критерии для данных, которые необходимо переместить в указанное место назначения (C-GET-SCU). Критерии задаются с помощью атрибута «соответствующие ключи», который мы видели при рассмотрении C-FIND и C-MOVE. Кроме того, передается информация об уровне запроса/извлечения (query/retrieve level), которая указывает, что мы хотим получить (все исследование, серию, изображение и т. д.). Иногда также может быть указан набор символов для управления кодировкой символов представлений значений возвращаемых данных DICOM. Затем C-GET SCP переключает роли на C-STORE SCU и отправляет команду C-STORE-RQ и любые данные, соответствующие критериям, пересылаются в указанное место назначения. C-GET SCP продолжает сообщать о состоянии операции C-STORE, например о количестве переданных файлов, количестве файлов, ожидающих передачи, любых предупреждениях и ошибках, возникающих во время операции и т. д. C-GET SCU также может инициировать операцию отмены (используя C-CANCEL-GET-RQ) в любое время, при этом любые текущие операции C-STORE также будут отменены, а статус «отменен» будет передан обратно в C-GET SCU. Структуры запроса и ответа C-GET показаны ниже для справки. Более подробную информацию можно найти в официальной документации DICOM, поскольку это практически вся теория, которую я могу осветить в этой статье.

Пример операции C-GET

Давайте перейдем к рассмотрению быстрого примера использования набора инструментов fo-dicom для выполнения операции C-GET SCU. Я собираюсь подключиться к серверу dcm-4chee-arc, работающему в virtualbox на моем компьютере. Я настроил его для прослушивания соединений через порт 11112 с IP 192.168.88.26. АЕТ сервера - XRAYARCHIVE. Наша программа, выполняющая роль C-GET SCU будет иметь АЕТ - LOCALAET. Мой сервер настроен на прием запросов от любых устройств с любыми заголовками AE. Поэтому АЕТ нашей программы вообще не обязательно указывать на сервере в отличие от случая, когда используется запрос C-MOVE. В общем случае это неправильный подход, и серверы должны проверять валидность АЕТ  вызывающих устройств в целях безопасности. Выше уже обсуждалось различие между C-GET и C-MOVE. На мой взгляд более простым может быть такое объяснение с точки зрения пользователя. При запросе C-MOVE мы "поручаем" серверу отправить данные нам или другому пользователю. Поэтому сервер должен точно "знать" адрес, куда их посылать. К тому же "приёмник" должен иметь неизменный статический IP и порт и быть готовым получить эти данные, т.е. иметь запущенный сервис C-STORE SCP. Если "приёмник" и сервер находятся в разных сетях, то неизбежно возникают проблемы маршрутизации. А в случае с C-GET мы только "спрашиваем" у сервера разрешения и сами "забираем" нужные нам данные, а сервер их просто отдает. В этом случае нам не нужен статический адрес и порт, все вопросы маршрутизации решаются программно при установлении ассоциации. Т.о. с точки зрения пользователя это намного проще. Единственное, что нужно предусмотреть в программе - куда и с каким именем сохранять принимаемые файлы.

Я собираюсь отправить на свой тестовый сервер сначала запрос C-FIND, чтобы найти исследования DICOM, относящиеся к конкретному пациенту "Anonymous" (который уже заведомо есть на моем сервере 🙂 ), далее (чтобы немного усложнить пример) в этих исследованиях найти серию по определенному описанию (тег Series Description) "KNEE LEFT*", затем получить изображения этой серии и сохранить их в файловой системе моего компьютера. В ходе всей операции мой C#Net-клиент действует в роли C-GET SCU и при необходимости переключает роли на C-STORE SCP, а удаленный DICOM-сервер действует как C-GET SCP, так и C-GET SCU. Код должен быть понятен, если вы понимаете теорию этой операции, которая была объяснена в предыдущих разделах.

У меня есть один пациент, у которого одно исследование, в котором три серии по одному изображению. По заданному выше условию мы должны в итоге "найти" и загрузить всего одну серию, т.е. одно изображение:

Код разделил на куски для лучшей читабельности. Если Вы сравните его с кодом из примера в материале "Основы DICOM с использованием .NET и C#: операции запроса и извлечения (C-MOVE)", то увидите, что он отличается буквально несколькими строками. Процедуры поиска исследований и серий не отличаются вообще, поэтому я не буду их здесь приводить. Весь код программы можно будет скачать по ссылке ниже.

Итак, в процедуре Main задаем вышеизложенную логику.

if (await GetDicomStudyUidsAsync(patientName)) 
                {
                    foreach (string studyUID in studyUIDs)
                    {
                        if (await GetDicomSeriesUidsAsync(studyUID))
                        {
                            foreach (string seriesUID in seriesUIDs)
                            {
                                GetSeriesAsync(studyUID, seriesUID);
                            }
                        }
                        else
                        {
                            LogToDebugConsole("No series found");
                        }
                    }
                }
                else
                {
                    LogToDebugConsole("No studies found");
                }

Процедура запроса C-GET для получения найденных серий. Обратите внимание на строки var pcs = DicomPresentationContext.GetScpRolePresentationContextsFromStorageUids(... и clientGET.OnCStoreRequest += (DicomCStoreRequest req) =>.... Здесь мы указываем, какие синтаксисы передачи поддерживает наша программа и задаем обработчик для роли C-CTORE-SCP, когда программа в процессе ассоциации переключится на приём данных.

  private static async void GetSeriesAsync(string studyUid, string seriesUid)
        {
            try
            {
                int numberInstances = 0; // Будем считать сколько файлов сохранили
                // Создаем клиент для установления ассоциации
                var clientGET = DicomClientFactory.Create(QRServerHost, QRServerPort, false, localAET, QRServerAET);
                clientGET.AssociationRejected += ClientOnAssociationRejected;
                clientGET.AssociationAccepted += ClientOnAssociationAccepted;
                clientGET.AssociationReleased += ClientOnAssociationReleased;

                // Создание запроса C-GET на получение серии по ее УИДам
                var cGetRequest = new DicomCGetRequest(studyUid, seriesUid);                              
                // Добавляем контексты представлений, которые можем принимать 
                // На самом деле их намного больше, но здесь и этого достаточно, для примера
                var pcs = DicomPresentationContext.GetScpRolePresentationContextsFromStorageUids(
                             DicomStorageCategory.Image,
                             DicomTransferSyntax.ExplicitVRLittleEndian,
                             DicomTransferSyntax.ImplicitVRLittleEndian);
                
                // Обработчик для роли C-CTORE-SCP для получения файла             
                clientGET.OnCStoreRequest += (DicomCStoreRequest req) =>
                {
                    if (SaveImage(req.Dataset)) // Если изображение сохранено возвращаем статус успеха 
                    {
                        LogToDebugConsole($"Save dicom file: {req.Dataset.GetSingleValueOrDefault(DicomTag.SeriesInstanceUID, "Unknown").Trim()}/" +
                            $"{req.Dataset.GetSingleValueOrDefault(DicomTag.SOPInstanceUID, "Unknown").Trim()}");
                        numberInstances++;
                        return Task.FromResult(new DicomCStoreResponse(req, DicomStatus.Success));
                    }
                    else
                    {
                        return Task.FromResult(new DicomCStoreResponse(req, DicomStatus.ProcessingFailure));
                    }
                    
                };
                clientGET.AdditionalPresentationContexts.AddRange(pcs);                

                await clientGET.AddRequestAsync(cGetRequest);
                await clientGET.SendAsync();
                LogToDebugConsole($"Get {numberInstances} dicom files in series");
            }
            catch (Exception e)
            {
                LogToDebugConsole($"Error move series: {e.StackTrace}");
            }
        }

Процедура сохранения dicom-файла. Файл будет сохранен в папке программы. Для этого сначала создается папка для серии с названием по УИДу серии (Series Instance UID), а в ней сохраняется файл изображения с именем = УИД изображения (SOP Instance UID). На что ещё обратить внимание? Пришлось использовать устаревшую функцию dataset.AutoValidate = false, чтобы отключить автоматическую проверку валидности dicom-тегов из-за того, что выбранные мной для примера файлы в UID содержат ведущие 0 в группах. А по последним изменениям к стандарту это не допускается и без отключения проверки процедура будет давать ошибку. см материал "Типы данных в DICOM"

[Obsolete]
        private static bool SaveImage(DicomDataset dataset)
        {
            try
            {   // Отключение проверки тегов
                dataset.AutoValidate = false;
                var seriesUid = dataset.GetSingleValueOrDefault(DicomTag.SeriesInstanceUID, "Unknown").Trim();
                var sopUid = dataset.GetSingleValueOrDefault(DicomTag.SOPInstanceUID, "Unknown").Trim();                              
                if (!Directory.Exists(seriesUid))
                {
                    Directory.CreateDirectory(seriesUid);
                }
                string pathDicomFileName = Path.Combine(seriesUid, sopUid) + ".dcm"; 
                // Записываем файл на диск
                DicomFile dcmFile = new DicomFile(dataset);
                dcmFile.Save(pathDicomFileName);
                if (File.Exists(pathDicomFileName))
                { return true; }
                else { return false; }                               
            }
            catch (Exception e)
            {
                LogToDebugConsole($"Error save dicom file: {e.StackTrace}");
                return false;
            }
        }

Библиотека fo-dicom позволяет нам создать сам запрос C-GET всего одной строчкой - var cGetRequest = new DicomCGetRequest(studyUid, seriesUid);
Причем DicomCGetRequest DicomCMoveRequest имеет три перегрузки: можно было как углубиться ещё на один уровень - запросить для получения определенное изображение из серии, так и вообще-то остановиться на уровне исследований. Другими словами можно создавать запросы на получение всего исследования, всей серии или конкретного изображения. Но в отличии от C-MOVE необходимо определять также обработчик для роли C-STORE-SCP.
Результат выполнения программы для приведенного примера:

Так как вывод очень длинный из-за перечисления поддерживаемых сервером синтаксисов передачи, то здесь я его "укоротил", вырезав часть и оставив только существенные сведения, из которых наглядно видно установление ассоциаций для двух запросов C-FIND и одного C-GET, а также результат - имя сохраненного файла.

Заключение

На этом завершается статья о том, как работает составная операция C-GET. Составные службы C-FIND, C-MOVE и C-GET составляют основу для всех операций запроса и поиска в любых рабочих процессах, связанных с DICOM. В следующем статье этой серии я расскажу еще об одной составной операции под названием «C-STORE». Эта услуга обеспечивает отправку данных другим DICOM-устройствам, например для сохранения в архиве PACS.

 

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

 

Основы DICOM с использованием .NET и C#: понимание ассоциации/согласования

Введение

Это часть  серии статей о стандарте DICOM, в которых мы пытаемся получить краткое представление о стандарте, а также познакомиться с прикладными инструментами для применения на практике. Вы также можете просмотреть другие материалы, чтобы освоить ряд тем, включая кодирование DICOM, SOP и IOD. Руководство под названием «Основы DICOM с использованием .NET и C# — понимание проверки DICOM» также будет очень полезно для понимания нижеизложенного материала. Также здесь предполагается, что вы знаете основы C# или любого эквивалентного объектно-ориентированного языка, такого как Java или C++. Базовое понимание сетевых технологий также будет полезно, но не обязательно.

Сеть DICOM на первый взгляд выглядит очень загадочной. Благодаря уникальному жаргону с такими словами, как «Абстрактный синтаксис», «Единицы данных протокола», «Контекст приложения», «Контекст представления» и т. д., любой новичок в стандарте может поначалу его полностью отпугнуть. Но как только вы начнете понимать, что они на самом деле означают, и проявите некоторое терпение, все действительно обретет смысл, и вся область связи DICOM начнет выглядеть интересно и даже весело. Я расскажу о некоторых фундаментальных терминах, которые вам необходимо знать, прежде чем вы углубитесь в написание программных приложений, которые используют или предоставляют услуги DICOM из/в другие программные приложения, поддерживающие DICOM. В этом уроке я буду использовать множество примеров, чтобы помочь вам лучше понять эти термины.

Контекст представления, контекст приложения, абстрактный синтаксис и синтаксис передачи в DICOM

Если вы читали мои другие статьи из этой серии, вы помните, что стандарт DICOM помогает устройствам, которые могут работать в совершенно разных операционных системах, обмениваться друг с другом объектами DICOM, такими как изображения, формы сигналов (например, ЭКГ) и диагностические отчеты. . Мы также увидели, что прежде чем произойдет какой-либо фактический обмен данными, два устройства должны согласовать «диалект» DICOM, на котором они говорят, или, более формально, «синтаксис передачи» ("transfer syntax"), как его называет стандарт. Этот синтаксис передачи определяет порядок байтов, используемый в этой операционной системе (с прямым порядком байтов или с обратным порядком байтов), тип сжатия, если оно используется, а также тип кодирования VR используемого (явный или неявный). Вы также помните концепцию пользователей класса обслуживания (или SCU), а также поставщиков классов обслуживания (или SCP), а также то, что одно и то же устройство может выполнять разные роли при обмене информацией с другими устройствами. Например, устройство A может играть роль C-Find SCU, когда необходимо запросить набор результатов от другого устройства B (которое будет воспроизводить роль C-Find SCP), но устройство A также может играть роль C-Store SCP, когда ему передаются любые интересующие его результаты (в данном случае устройство B играет роль C-Store SCU).

DICOM-соединение является фактически сетевым соединением между двумя устройствами DICOM, во время которого происходит первоначальное согласование используемого диалекта, а также фактическая передача данных, которая происходит после ассоциации. Во время ассоциации может выполняться ряд операций. Каждая из этих операций может быть полностью независимой друг от друга и помогать приложениям DICOM обмениваться друг с другом различными типами объектов DICOM. Когда устройство (обычно SCU) пытается открыть соединение с другим устройством (обычно SCP), перед открытием соединения проверяется некоторая проверка входной информации. Это включает в себя такие проверки, как то, настроен ли уже «Вызывающий AE» в «Вызываемом AE». (АЕ, если помните, определяется как объект приложения). Другими словами -  разрешено ли настройками вызываемого устройства (например, сервера) подключение вызывающего устройства (например, рабочей станции врача). Это функция безопасности, которая реализована во многих приложениях DICOM, чтобы гарантировать, что конфиденциальная информация не будет передана «незаконным» приложениям, запрашивающим данные.

Затем вызываемый AE также проверяет, может ли он обрабатывать запрашиваемый тип службы (определяемый UID класса SOP) (например, хранение изображений CT, запрос/извлечение или печать), и может ли он обрабатывать эту операцию службы с помощью синтаксиса передачи, который объявляется  вызывающим AE. Вызываемый AE может также выполнить дополнительные проверки, чтобы убедиться, что он имеет достаточно ресурсов для обработки этой рабочей нагрузки, т.к. в момент запроса ассоциации может быть высокий трафик и запрос на ассоциацию может быть отклонен по этой причине, даже если все остальное хорошо. Если первоначальные проверки прошли успешно, происходит успешная передача фактических данных, соответствующих классу SOP, также известному как определение информационного объекта или IOD, как они называются в DICOM. После передачи всех данных, относящихся к операциям, ассоциация может быть прекращена либо инициирующей стороной (SCU), либо иногда также и SCP. Вот и все. Вот суть того, что происходит во время ассоциации. Но прежде чем мы углубимся в детали, я хочу осветить некоторые жаргоны, специфичные для процесса ассоциации. Это поможет вам понять последующие примеры кода.

Иллюстрация выше должна дать хорошее представление о том, как работает связь DICOM между двумя устройствами на очень высоком уровне. Весь процесс начинается с того, что инициирующая сторона (часто SCU, также известная как вызывающий AE — здесь это устройство A) устанавливает сокетное соединение с другой стороной (часто SCP, также известной как вызываемый AE — здесь это устройство B). Это делается путем предоставления IP-адреса, а также номера порта во время установления соединения через сокет. Некоторые проверки безопасности выполняются, чтобы гарантировать, что вызывающая сторона уже зарегистрирована в базе данных вызываемого AE, а если нет, то соединение здесь не разрешено. Если все в порядке, соединение с сокетом установлено.

Переговоры об ассоциации в DICOM

Затем происходит процесс, называемый согласованием ассоциации, во время которого вызывающий AE отправляет некоторые объекты, называемые контекстами представления другой стороне. Инициатор ассоциации может передать несколько контекстов представления, но стандарт DICOM ограничивает их число максимум 128. Каждый объект контекста представления сам по себе состоит из двух объектов. Один из них назывался «Абстрактный синтаксис» ("Abstract Syntax"), а другой — «Список синтаксисов передачи» (Transfer Syntax List). Абстрактный синтаксис определяет тип класса SOP (указанный через UID SOP), а также роль, которую он хочет играть — SCU или SCP. Вызываемый AE должен поддерживать этот абстрактный синтаксис, в противном случае он полностью отклоняет запрос на ассоциацию. Например, вызывающий AE может указать, что ему нужна услуга C-Find SCP от вызываемого AE. Если эта услуга предоставляется, вызываемый AE затем просматривает список синтаксисов передачи, который был ему отправлен. Это определяет диалект DICOM, на котором желает говорить вызывающий AE. Например, вызывающий AE может пожелать использовать явный VR с прямым порядком байтов, указанный через UID 1.2.840.10008.1.2.1. Вызываемый AE может не поддерживать этот синтаксис передачи и может просмотреть другие синтаксисы передачи в списке, чтобы увидеть, есть ли в списке что-нибудь, что он понимает. Если ни один из синтаксисов передачи в списке не поддерживается, запрос на ассоциацию отклоняется.

Здесь я хочу упомянуть, что должен быть хотя бы один синтаксис передачи, который должны поддерживать все приложения DICOM. Этот синтаксис передачи представляет собой неявный VR с прямым порядком байтов, обозначенный UID 1.2.840.10008.1.2. Это единственный обязательный синтаксис передачи DICOM, который должны поддерживать все приложения DICOM. Проблема с этим синтаксисом заключается в том, что, поскольку имя указывает, кодировка VR является неявной и, следовательно, требует, чтобы вызываемое приложение имело обновленный словарь DICOM, чтобы иметь хоть какой-то смысл входящих данных. Тем не менее рекомендуется всегда использовать какой-либо явный синтаксис передачи кодировки VR, где это возможно, поскольку тип VR можно понять из самих переданных данных. Обратите внимание, что стандарт также разрешает использование частных абстрактных синтаксисов, а также частных синтаксисов передачи между сторонами. Дополнительную информацию см. в официальной документации DICOM. На этом этапе переговоров ассоциация либо принимается с уведомлением вызывающего AE о контекстах представления, которые приемлемы в ответном сообщении, либо ассоциация отклоняется вызываемым AE. Также обратите внимание, что может быть более одного контекста представления, принимаемого как несколько классов SOP, или устройством могут поддерживаться «абстрактные синтаксисы». Наряду с этой информацией также указывается конкретный синтаксис передачи, который поддерживается для этого контекста представления.

Также нужно упомянуть здесь, что существует нечто, называемое расширенным согласованием (Extended Negotiation), во время которого между устройствами происходят дополнительные согласования для согласования конкретной семантики для определенного класса SOP, такого как C-FIND, C-GET. и т. д. Это когда такая информация, как роль, которую устройства хотят играть (SCU, SCP или оба), максимальное количество асинхронных операций, которые могут быть вызваны/выполнены устройством, идентификационная информация пользователя в виде имени пользователя, Kerberos Билеты аутентификации, утверждения SAML, веб-токены JSON и т. д. также могут передаваться во время этого процесса. Обмениваемая идентификационная информация пользователя, например, может использоваться для ведения контрольного журнала, предоставления доступа или ограничения результатов, возвращаемых на определенный организационный уровень, к которому принадлежит пользователь, и т. д. Просто по этой теме можно очень многое рассказать, и я на этом пока остановимся. Не все поставщики реализуют все эти возможности или могут игнорировать их при установлении ассоциации. В некоторых ситуациях ассоциации по-прежнему могут устанавливаться с некоторыми значениями по умолчанию, принятыми поставщиком услуг (например, если роль не указана), а в других ситуациях ассоциации также отклоняются. Этот аспект DICOM в некоторых случаях определенно может вызвать проблемы совместимости, поэтому перед покупкой продукта проведите некоторое тестирование. Пожалуйста, посмотри Документация DICOM содержит подробные сведения об этом .

После завершения основных, а также любых расширенных переговоров, вызывающий AE знает, какие услуги ожидать от вызываемого AE, и это завершает то, что называется установлением ассоциации в сети DICOM. На этом этапе вызывающий AE может начать отправлять команды DICOM вместе со всеми связанными данными вызываемому AE. На диаграмме ниже показаны различные команды DIMSE, которые передаются туда и обратно во время согласования ассоциации.

Копаем глубже в DIMSE

При попытке установления связи два объекта DICOM передают друг другу серию DIMSE (элементов службы сообщений DICOM). DIMSE для операций являются тем же, чем IOD для данных. Команды DIMSE состоят из той же модульной группы элементов DICOM, что и IOD DICOM, определяющей такую ​​​​информацию, как уникальный идентификатор сообщения, тип команды или операции, приоритет команды (используется очень редко), DICOM AE, запрашивающий эту операцию, а также а также индикатор, указывающий, есть ли данные, сопровождающие эту команду. Если в команде флаг данных установлен в значение true, то сразу после команды передается один и только один объект данных IOD. Вызываемый AE или SCP затем отвечает на эту команду (и любые сопровождающие ее данные) ответным сообщением, указывающим результаты операции.

«A-ASSOCIATE-RQ» — это команда DIMSE, передаваемая запрашивающей стороной удаленному узлу при инициировании ассоциации. Принимающая сторона может просмотреть контекст представления и принять его, ответив сообщением «A-ASSOCIATE-AC», или она может отклонить ассоциацию, используя сообщение «A-ASSOCIATE-RJ». Если/когда ассоциация успешно установлена, два объекта DICOM передают друг другу другие DIMSE на основе задействованной операции. Например, когда сканер КТ решает передать серию изображений на сервер хранения PACS, он сначала устанавливает ассоциацию, а затем отправляет команду DIMSE «C-STORE-RQ», за которой следует экземпляр IOD изображения КТ, который необходимо сохранить. SCP C-Store отвечает ответом об успехе или сбое (с использованием объекта C-STORE-RSP), указывающим результат операции. C-Store SCU может продолжать передавать серию дополнительных команд для каждого изображения, которое ему необходимо сохранить, и процесс продолжается между двумя устройствами по той же схеме. После завершения операций считается хорошей практикой «освободить» ассоциацию, чтобы освободить вычислительные ресурсы и сетевые соединения. Это можно сделать, отправив сообщение A-RELEASE-RQ. Ассоциации также могут быть «прерваны» в любой момент любой стороной в любой момент всего процесса (с использованием сообщения «A-ABORT»), и одноранговые устройства должны иметь возможность корректно обрабатывать такие сценарии.

Пример согласования ассоциации с использованием Fellow Oak (набор инструментов fo-dicom)

Данный материал является моим (Александр Кузнецов) творческим переводом статьи "DICOM Basics using .NET and C# - Understanding Association/ Negotiations", автором которой является Saravanan Subramanian. Приведенные примеры кода основаны на первоисточнике, но переработаны мной в связи с существенными изменениями определений процедур используемой библиотеки.

Как вы можете видеть из моего небольшого примера кода ниже, я сначала создаю контекст представления с одним абстрактным синтаксисом (класс Verification SOP) вместе со списком синтаксисов передачи, с которыми я могу справиться на своей стороне. Затем я формирую ассоциацию с удаленным AE и в это время передаю контексты презентации. Несмотря на то, что в этом примере я передаю только один, ничто не мешает вам запросить длинный список контекстов представления, каждый из которых включает совершенно другой класс SOP или абстрактный синтаксис вместе со всеми связанными синтаксисами передачи. Затем я спрашиваю удаленный AE, поддерживает ли он определенный идентификатор контекста представления (вот почему нам нужно отслеживать переданные нами идентификаторы). Если да, то я также проверяю, какой тип синтаксиса передачи он предпочитает использовать при обработке этого конкретного абстрактного синтаксиса или класса SOP. Большинство приложений DICOM должны поддерживать явный синтаксис передачи VR, поскольку они не требуют от приложения наличия огромного словаря DICOM. Как вы помните, явное кодирование VR включает в себя включение типа VR вместе с номером группы и тега, чтобы принимающее приложение могло быстрее анализировать данные.

В коде, показанном ниже, я регистрирую три дополнительных метода обратного вызова в клиенте DICOM, которые помогают отслеживать различные события, связанные с установлением и завершением ассоциации. Если ассоциация не может быть установлена ​​по какой-либо причине, мы можем узнать причину, используя параметр AssociationRejectedEventArgs , который передается в обратный вызов отклонения ассоциации. С другой стороны, если ассоциация была установлена, мы можем получить дополнительные сведения об установленной ассоциации, используя параметр AssociationAcceptedEventArgs , который передается в обратный вызов, принятый ассоциацией. Подробности установленной ассоциации, такие как абстрактный синтаксис, а также согласованные синтаксисы передачи, выводятся в консоль программы.

using FellowOakDicom.Network;
using FellowOakDicom.Network.Client;
using FellowOakDicom.Network.Client.EventArguments;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace AssociationDicom
{
    class Program
    {
        static async Task Main(string[] args)
        {
            try
            {
                //replace these with your settings
                //Here, I am using Dr.Dave Harvey's public server
                //please be careful not to send any confidential info as all traffic is logged
                var dicomRemoteHost = "www.dicomserver.co.uk";
                var dicomRemoteHostPort = 104;
                var useTls = false;
                var ourDotNetTestClientDicomAeTitle = "TESTAET";
                var remoteDicomHostAeTitle = "DAVEHARVEYS";

                //создаем клиент эхо-проверки DICOM с обработчиками

                var client = DicomClientFactory.Create(dicomRemoteHost, dicomRemoteHostPort, useTls, ourDotNetTestClientDicomAeTitle, remoteDicomHostAeTitle);
                var dicomCEchoRequest = new DicomCEchoRequest();

                dicomCEchoRequest.OnResponseReceived += OnEchoResponseReceivedFromRemoteHost;


                //добавляем обработчики событий для общей информации о подключении ассоциации
                client.AssociationAccepted += ClientOnAssociationAccepted;                                
                client.AssociationRejected += ClientOnAssociationRejected;
                client.AssociationReleased += ClientOnAssociationReleased;

                await client.AddRequestAsync(dicomCEchoRequest).ConfigureAwait(true);

                //отправляем запрос на проверку на удаленный сервер DICOM
                await client.SendAsync().ConfigureAwait(true);
                
                Console.ReadLine();

            }
            catch (Exception e)
            {
                //In real life, do something about this exception
                LogToDebugConsole($"Error occured during DICOM association request -> {e.StackTrace}");
            }
        }

        
        private static void OnEchoResponseReceivedFromRemoteHost(DicomCEchoRequest request, DicomCEchoResponse response)
        {
            LogToDebugConsole($"DICOM Echo Verification request was received by remote host");
            LogToDebugConsole($"Response was received from remote host...");
            LogToDebugConsole($"Verification response status returned was:{response.Status.ToString()}");
        }

        private static void ClientOnAssociationReleased(object sender, EventArgs e)
        {
            LogToDebugConsole("Assoacition was released");
        }

        private static void ClientOnAssociationRejected(object sender, AssociationRejectedEventArgs e)
        {
            LogToDebugConsole($"Association was rejected. Rejected Reason:{e.Reason}");
        }

        private static void ClientOnAssociationAccepted(object sender, AssociationAcceptedEventArgs e)
        {
            var association = e.Association;
            LogToDebugConsole($"Association was accepted by remote host: {association.RemoteHost} running on port: {association.RemotePort}");

            foreach (var presentationContext in association.PresentationContexts)
            {
                if (presentationContext.Result == DicomPresentationContextResult.Accept)
                {
                    LogToDebugConsole($"\t {presentationContext.AbstractSyntax} was accepted");
                    LogToDebugConsole($"\t Negotiation result was: {presentationContext.GetResultDescription()}");
                    LogToDebugConsole($"\t Abstract syntax accepted: {presentationContext.AbstractSyntax}");
                    LogToDebugConsole($"\t Transfer syntax accepted: {presentationContext.AcceptedTransferSyntax.ToString()}");
                }
                else
                {
                    LogToDebugConsole($"\t Presentation context with proposed abstract syntax of '{presentationContext.AbstractSyntax}' was not accepted");
                    LogToDebugConsole($"\t Reject reason was {presentationContext.GetResultDescription()}");
                }
            }
        }

        private static void LogToDebugConsole(string informationToLog)
        {
            Console.WriteLine(informationToLog);
        }
    }
}

Вывод, отображаемый в консоли программы при запуске приведенного выше примера кода, показан ниже. Из ответа видно, что сначала устанавливается связь между двумя устройствами DICOM, а затем следует ответ удаленного хоста о том, что он поддерживает службу проверки DICOM (здесь она действует как «Verification SCP»). Затем он освобождает ассоциацию, поскольку клиент не запрашивал никаких других операций.

Заключение

На этом завершается краткое вводное руководство по установлению/согласованию ассоциации DICOM. Это чрезвычайно важный аспект сети DICOM, и в этом коротком руководстве я постарался передать саму суть этого сложного аспекта связи DICOM так, чтобы это имело для вас смысл.

Скачать текст программы.

 

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

Введение

В этом материале я собираюсь начать исследование аспектов сети DICOM, связанных с поиском и извлечением таких артефактов, как изображения и структурированные отчеты, закодированные в формате DICOM, а также создать клиент DICOM («C-FIND SCU» в терминологии DICOM), который может запрашивать у удаленного узла (называемого «C-FIND SCP») информацию об объектах dicom. В последующем я постараюсь развить основу, заложенную в этой статье, чтобы показать, как получить эти артефакты для локальной проверки (с помощью операций DICOM «C-GET-SCU»).

Данный материал является моим (Александр Кузнецов) творческим переводом статьи "DICOM Basics using .NET and C# - Query and Retrieve Operations (C-FIND)", автором которой является Saravanan Subramanian с моими изменениями и дополнениями. Приведенные примеры кода основаны на первоисточнике, но переработаны мной в связи с существенными изменениями определений процедур используемой библиотеки.

Запрос данных DICOM с помощью C-FIND

Как описано в материале по ассоциациям DICOM, вы должны понимать, что прежде чем любые два устройства DICOM смогут обмениваться запросами на обслуживание и результатами друг с другом, сначала необходимо установить ассоциацию. Во время создания/согласования ассоциации происходит несколько действий. Два устройства сначала проверяют, доступны ли они и действительно ли они могут «общаться» с DICOM (выполняется с помощью «DICOM ping», более формально известного как «C-Echo», с которым можно ознакомиться по материалу Основы DICOM с использованием .NET и C#: понимание проверки DICOM (C-ECHO)). Они также проверяют, разрешено ли им общаться с другими с точки зрения безопасности DICOM. Это делается путем проверки того, настроены ли конфигурации DICOM AE с обеих сторон. Затем они проверяют, поддерживают ли они желаемую операцию DICOM, которую часто называют «абстрактным синтаксисом». Затем, в качестве последнего шага, они должны проверить, могут ли они договориться о «синтаксисе передачи» обмениваемой информации (например, о кодировании VR, сжатии и порядке байтов, которые будут использоваться). Все эти шаги необходимо выполнить, прежде чем приступить к выполнению фактических желаемых операций.

На этапе оперативного выполнения два узла DICOM обмениваются друг с другом  так называемыми элементами службы сообщений DICOM (DIMSE). Эти объекты помогают указать фактическую операцию, которую необходимо выполнить, и сопровождаются дополнительными данными, называемыми определениями информационных объектов (IOD) - текстовая информация или изображения. Вместе эти сервисные элементы и информационные объекты, с которыми они действуют, образуют то, что в DICOM называется парами сервисных объектов (SOP). Типы обмениваемых DIMSE различаются в зависимости от типа выполняемой операции. Для операций запроса DICOM сообщение C-FIND-RQ передается от запрашивающего устройства (устройства A) к C-Find SCP (устройству B), как показано на рисунке ниже. Сообщение запроса C-FIND-RQ сопровождается IOD, который состоит как из критериев поиска, так и из атрибутов данных, которые необходимо вернуть, если совпадения найдены.

Структура объектов запроса сообщения C-FIND-RQ и ответа на сообщение показана ниже. Представленные таблицы представляют собой снимки экрана из документа стандарта DICOM, часть 7, в котором рассматриваются основы обмена сообщениями. Запрошенный SCP возвращает ответное сообщение «C-FIND-RSP» для каждого объекта, соответствующего идентификатору, указанному в запросе. Обратите внимание, что сами ответы возвращаются в виде пар DIMSE-IOD, при этом объект DIMSE передает статус операции ответа (например, «pending», когда в последовательности есть дополнительные результаты, и «successful», когда все данные были отправлены). Удаленная сторона может также передавать другие ответы на команды DIMSE в таких ситуациях, как отсутствие совпадающих результатов или возникновение других ошибок во время операции поиска. Клиент также может инициировать операцию отмены в любое время (используя команду C-CANCEL-FIND-RQ), пока выполняется операция поиска, после чего C-FIND-SCP отменит операцию поиска и вернет команду DIMSE со статусом «cancelled». Дополнительную информацию см. в официальной документации DICOM, поскольку спецификация обширна, и в этой статье я могу предоставить только обзор.

Для всех служб запросов и извлечения данных (также называемых «составными службами») в DICOM поиск данных осуществляется с использованием так называемого «уровня запроса» ("query level"), который может быть «Patient Root», «Study Root» или «Patient/Study Only». Из них «Study Root» является наиболее популярным методом, используемым для операций запроса. Уровень «Patient/Study Only» был удален некоторое время назад, но вы можете найти старое программное обеспечение DICOM, которое все еще может его поддерживать. Вместе с уровнем запроса передается соответствующий набор фильтров, соответствующих выбранному уровню запроса. Удаленный узел использует эти фильтры как выражение поиска SQL, сопоставляющее его с соответствующими файлами DICOM, которые он может содержать. Обратите внимание, что официальный DICOM не разрешает запросы к реляционной базе данных. Однако некоторые поставщики предпочитают внедрять в свои продукты дополнительные возможности, «подобные SQL», которые работают поверх основных функций запросов DICOM, чтобы их поведение было очень похоже на любые инструменты SQL, с которыми конечные пользователи, возможно, уже знакомы. Сам стандарт DICOM определяет множество видов поисковой фильтрации с помощью шести типов сопоставлений. Пожалуйста, посмотрите официальную документацию DICOM по атрибутам поиска и сопоставлению для получения дополнительной информации.

Пример операции C-FIND

Давайте теперь быстро посмотрим на код. Как упоминалось ранее, операция C-FIND во время поиска использует иерархическую модель данных. «Уровнями запроса», указанными в DICOM, являются «Patient Root», «Study Root» и «Patient/Study Only». Однако важно также учитывать, что иерархия информационных режимов (сверху вниз) выглядит следующим образом: «ПАЦИЕНТ -> ИССЛЕДОВАНИЕ -> СЕРИЯ -> ИЗОБРАЖЕНИЕ/ЭКЗЕМПЛЯР». Знание этой иерархии важно для выполнения поиска в системах DICOM. Например, при поиске исследования необходимо указать атрибуты пациента (при использовании уровня запроса «Study Root»). В DICOM они называются «совпадающими ключами»("Matching Keys"). Кроме того, вам также необходимо явно указать атрибуты данных, которые вы хотите вернуть при обнаружении совпадения (называемые «ключами возврата»). Для поиска нужно гораздо больше, но давайте рассмотрим быстрый пример использования набора инструментов fo-dicom для выполнения операции C-Find. Я собираюсь подключиться к общедоступному серверу DICOM, предоставленному MedicalConnections, информация о котором представлено здесь.

По комментариям в коде в общем-то все должно быть понятно. Кратко: создаем Dicom-клиент с указанием атрибутов сервера, к которому подключаемся; создаем запрос на поиск исследований по фамилии; в параметрах запроса указываем элементы с пустыми значениями, реальные значения которые нам нужно получить; подключаем запрос к созданному клиенту и собственно отправляем запрос.

using FellowOakDicom.Network.Client.EventArguments;
using FellowOakDicom.Network;
using FellowOakDicom;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using FellowOakDicom.Network.Client;
using System.Threading;

namespace DicomCfindSample
{
    public class Program
    {
        // Dr. Harvey graciously provides a free DICOM server to connect and play with
        private static string QRServerHost = "www.dicomserver.co.uk";
        private static int QRServerPort = 104;
        private static string QRServerAET = "STORESCP";
        private static string AET = "FODICOMSCU";

        static async Task Main(string[] args)
        {
            try
            {
                // Создаем клиент для установления ассоциации
                var client = DicomClientFactory.Create(QRServerHost, QRServerPort, false, AET, QRServerAET);
                client.NegotiateAsyncOps();

                //Добавляем обработчик, который будет получать уведомления о любых отклонениях ассоциации.
                client.AssociationRejected += ClientOnAssociationRejected;

                //Добавляем обработчик для получения уведомлений о любой информации об ассоциации при успешных соединениях.
                client.AssociationAccepted += ClientOnAssociationAccepted;

                //Добавляем обработчик для уведомления об успешном освобождении ассоциации — это также может быть вызвано удаленным узлом.
                client.AssociationReleased += ClientOnAssociationReleased;

                // Создаём запрос на поиск исследований пациента с фамилией "Bowen", * - заменяет любое количество любых других символов
                DicomCFindRequest studyRequest = StudyCFindRequest("Bowen*");

                // Добавляем обработчик ответа на запрос - вывод результатов в консоль
                DicomCFindRequest.ResponseDelegate cFindResponseDelegate = LogStudyResultsFoundToDebugConsole;
                studyRequest.OnResponseReceived = cFindResponseDelegate;

                // Добавляем запрос на поиск исследований пациента 
                await client.AddRequestAsync(studyRequest);

                // Устанавливаем время ожидания, по истечении которого ассоциация будет разорвана принудительно  - 30 секунд
                CancellationTokenSource cancelToken = new CancellationTokenSource();
                CancellationToken TokenCancel = cancelToken.Token;
                DicomClientCancellationMode cancelMode = DicomClientCancellationMode.ImmediatelyReleaseAssociation;
                int timeToken = 30000;
                cancelToken.CancelAfter(timeToken);

                await client.SendAsync(TokenCancel, cancelMode); // Собственно посылка запроса с ожиданием результатов
                Console.ReadLine();
            }
            catch (Exception e)
            {
                //In real life, do something about this exception
                LogToDebugConsole($"Error occured during DICOM association request -> {e.StackTrace}");
            }            
        }
        public static DicomCFindRequest StudyCFindRequest(string patientName)
        {
            // Создание нового экземпляра запоса уровня "Study Root"
            var request = new DicomCFindRequest(DicomQueryRetrieveLevel.Study);

            // Чтобы получить интересующие атрибуты нужно указать их заранее с пустыми параметрами, как показано ниже
            request.Dataset.AddOrUpdate(DicomTag.PatientName, string.Empty);
            request.Dataset.AddOrUpdate(DicomTag.PatientID, string.Empty);
            request.Dataset.AddOrUpdate(DicomTag.StudyDate, string.Empty);
            request.Dataset.AddOrUpdate(DicomTag.StudyInstanceUID, string.Empty);

            // Фильтр по имени пациента 
            request.Dataset.AddOrUpdate(DicomTag.PatientName, patientName);

            // Specify the encoding of the retrieved results
            // here the character set is 'Latin alphabet No. 1'
            request.Dataset.AddOrUpdate(DicomTag.SpecificCharacterSet, "ISO_IR 100");                    
            return request;
        }
        private static void LogStudyResultsFoundToDebugConsole(DicomCFindRequest request, DicomCFindResponse response)
        {
            // Пока не получен сигнал о выполнении извлекаем из ответа значения тегов и выводим в консоль
            if (response.Status.ToString() == "Success")
            {
                LogToDebugConsole("The search is over...");
            }
            else
            {
                var patientName = response.Dataset.GetSingleValueOrDefault(DicomTag.PatientName, string.Empty);
                var patientID = response.Dataset.GetSingleValueOrDefault(DicomTag.PatientID, string.Empty);
                var studyDate = response.Dataset.GetSingleValueOrDefault(DicomTag.StudyDate, new DateTime());
                var studyUID = response.Dataset.GetSingleValueOrDefault(DicomTag.StudyInstanceUID, string.Empty);

                LogToDebugConsole("Matched Result...");
                LogToDebugConsole($"Patient Found ->  {patientName} ");
                LogToDebugConsole($"Patient ID ->  {patientID} ");
                LogToDebugConsole($"Study Date ->  {studyDate} ");
                LogToDebugConsole($"Study UID ->  {studyUID} ");
                LogToDebugConsole("\n");
            }
        }        
        private static void ClientOnAssociationReleased(object sender, EventArgs e)
        {
            LogToDebugConsole("Assoacition was released");           
        }
        private static void ClientOnAssociationRejected(object sender, AssociationRejectedEventArgs e)
        {
            LogToDebugConsole($"Association was rejected. Rejected Reason:{e.Reason}");            
        }
        private static void ClientOnAssociationAccepted(object sender, AssociationAcceptedEventArgs e)
        {
            var association = e.Association;
            LogToDebugConsole($"Association was accepted by remote host: {association.RemoteHost} running on port: {association.RemotePort}");

            foreach (var presentationContext in association.PresentationContexts)
            {
                if (presentationContext.Result == DicomPresentationContextResult.Accept)
                {
                    LogToDebugConsole($"\t {presentationContext.AbstractSyntax} was accepted");
                    LogToDebugConsole($"\t Negotiation result was: {presentationContext.GetResultDescription()}");
                    LogToDebugConsole($"\t Abstract syntax accepted: {presentationContext.AbstractSyntax}");
                    LogToDebugConsole($"\t Transfer syntax accepted: {presentationContext.AcceptedTransferSyntax.ToString()}");
                }
                else
                {
                    LogToDebugConsole($"\t Presentation context with proposed abstract syntax of '{presentationContext.AbstractSyntax}' was not accepted");
                    LogToDebugConsole($"\t Reject reason was {presentationContext.GetResultDescription()}");
                }
            }           
        }
        private static void LogToDebugConsole(string informationToLog)
        {
            Console.WriteLine(informationToLog);
        }

    }
}

В результате выполнения получим в выводе на консоль список исследований, которые удовлетворяют условию поиска:

Заключение

На этом завершается статья о том, как работает операция запроса C-FIND в DICOM. Это делает первый шаг в общем процессе выполнения большинства операций поиска информации в DICOM. В следующем материале этой серии, посвященной стандарту DICOM, я расскажу о другой составной операции под названием «C-MOVE», которая помогает дать указание удаленному серверу DICOM («C-MOVE SCP») передать данные DICOM обратно клиенту или в указанное место.

Скачать текст программы.

Экспорт dicom на PACS-сервер

Бесплатная программа ExportToPacsFree, единственным назначением которой является отправка выбранных dicom-файлов на заданный сервер PACS.

Никаких предварительных обработок файлов не предусмотрено. Если Вам необходимо перед отправкой изменить какие-либо элементы или исправить кодировку кириллицы, то обратите внимание на программу ExportToPacs или Dicom Autoexport, которые имеют такие функции.

Разобраться с порядком работы с программой не сложно, поэтому отдельной справки я не делал. Однако же можно воспользоваться Инструкцией к программе ExportToPacs в части касающейся.

Самое сложное - правильно ввести реквизиты сервера. Значение AET должны соответствовать типу VR "AE" - см. "Типы данных в DICOM", Host Name - IP-адрес или доменное имя хоста dicom-устройства, Dicom Port - положительное целое число. Доступность сервера можно проверить, нажав на кнопку "Echo".

Нажатием на кнопку "File" или "Folder" открываем диалог выбора файлов, которые необходимо отправить. Нажатием на кнопку "SEND" отправляем. Успешно переданные файлы отмечаются в таблице зеленым цветом, неудачно - красным. Слева находится поле текстового лога, в котором можно увидеть подробности отправки.

Настройки сервера сохраняются при выходе из программы, поэтому их не нужно будет вводить каждый раз заново.

Enjoy!

Программа для проверки DICOM-эха

Небольшая программа для "прозвонки" dicom-устройств с целью проверки их доступности - реализация dicom-службы C-ECHO. Подробнее об этой службе читайте в материале "Основы DICOM с использованием .NET и C#: понимание проверки DICOM (C-ECHO)".

Здесь я публикую программу аналогичную приведенной в упомянутом материале, но собранную для выполнения в среде .NET Core 6 в виде одного файла.

Не прилагаю никакой справки в виде отдельного документа, т.к. использование программы достаточно тривиально. Запускаем, вводим атрибуты "прозваниваемого" dicom-устройства, нажимаем кнопочку, ожидаем результат "Success" или  "Failed", т.е. связь с устройством или есть, или её нет.

Единственное, на что обращаю внимание: правильность ввода атрибутов в поля. Значение AET должны соответствовать типу VR "AE" - см. "Типы данных в DICOM", Host Name - IP-адрес или доменное имя хоста dicom-устройства, Dicom Port - положительное целое число.

Условия распространения: Freeware.

 

 

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

Введение

В этом материале я расскажу о чрезвычайно важной и очень полезной функции сети DICOM, как проверка возможности соединения DICOM между устройствами.

Вещи, которые многие из нас считают само собой разумеющимися, такие как назначение процедуры сканирования при входе в клинику, архивирование изображений, полученных с помощью таких методов, как КТ или МРТ, позднее извлечение и просмотр этих отсканированных изображений, отчет о диагнозе и печать нужных изображений на пленку не могли бы появиться без сетевых служб DICOM обеспечивающих связь между устройствами в больничной сети, даже если они произведены разными производителями. Если вы хотя бы немного знакомы с компьютерными сетями, вы должны знать популярный термин под названием «Пинг» . Это утилита, которая так или иначе реализована в большинстве операционных систем и позволяет выявлять неполадки, связанные с компьютерной сетью. Используя эту утилиту, можно проверить доступность определенного хоста или сети, а также измерить общее время прохождения туда и обратно тестовых сообщений. Хотя это позволяет нам проверить, доступен ли конкретный хост или нет, это не говорит нам, например, поддерживает ли устройство DICOM или включены ли его функции DICOM. Здесь на помощь приходит Служба проверки DICOM. Но прежде чем идти дальше, нам нужно разобраться в некотором техническом жаргоне, который понадобится, чтобы лучше понять эту тему.

Внутри любой сети DICOM может существовать множество приложений, работающих на различных устройствах, которые потребляют, а также предоставляют услуги друг другу (вспомните объяснение пользователей классов обслуживания и поставщиков классов обслуживания из материала "Основы DICOM с использованием .NET и C#: понимание файла DICOM"). Например, может существовать блок сканирования, такой как КТ, МРТ или УЗИ, сервер PACS, который обеспечивает хранение и извлечение отсканированных изображений, может быть несколько принтеров DICOM, которые печатают изображения и т.д. Все эти устройства могут запрашивать услуги или предоставлять услуги друг другу. На некоторых из этих устройств фактически может работать несколько программных приложений.

Например, на одном компьютере могут быть установлены два разных программного обеспечения DICOM: одно может быть программным обеспечением рабочей станции для просмотра, которое обеспечивает такие функции, как просмотр, а также запрос и извлечение изображений из удаленного архива. На том же компьютере может быть другое программное обеспечение, которое, возможно, отвечает за печать изображений DICOM. Оба эти приложения могут прослушивать связь с другими устройствами в сети, обмениваясь данными через совершенно разные порты (обратите внимание, что один объект приложения также может использовать два разных порта — один для передачи, а другой для приема). Каждое из этих приложений (независимо от того, работают они на отдельных устройствах или нет) известно на жаргоне DICOM как «Объект приложения» или сокращенно «AE» (Application Entity). Когда любые два из этих объектов взаимодействуют друг с другом в сети DICOM, объект, который является потребителем запрашиваемой услуги, называется пользователем класса обслуживания или SCU. Поставщик услуги известен как поставщик класса обслуживания или SCP. DICOM предусматривает, что каждый из этих объектов должен быть однозначно идентифицирован путем присвоения им уникального имени, называемого «Заголовком объекта приложения» (Application Entity Title - часто сокращенно «AET»). Заголовки часто кодируются цифрами и только заглавными буквами (подробнее об этом позже).

При устранении неполадок существующего соединения или настройке нового соединения между двумя устройствами используют предварительную проверку связи между двумя объектами, чтобы убедиться, что они могут говорить друг с другом на одном языке DICOM. Эта проверка осуществляется с помощью службы DICOM, называемой Службой проверки (также известной как C-Echo или неофициально называемой DICOM Ping). Эта проверка необходима, поскольку простой проверки доступности сети или физического соединения между двумя устройствами недостаточно, и они оба должны «говорить» на базовом уровне DICOM, чтобы иметь возможность ответить, чтобы понять, о чем спрашивают, прежде чем они смогут ответить «Да» или «Нет». Дальнейшая связь между двумя объектами может происходить только после установления этой первоначальной проверки. DICOM реализует эту первоначальную проверку через службу проверки DICOM, которая идентифицируется своим собственным уникальным идентификатором SOP (1.2.840.10008.1.1).

Во время этой операции проверки между двумя устройствами происходит обмен командными объектами, называемыми DIMSE. Эти DIMSE (расшифровываются как “DICOM Message Service Element” - «Элемент службы сообщений DICOM») кодируются по тому же знакомому шаблону элементов DICOM, который мы уже видели при работе с файлами DICOM, и имеют форму объектов запроса и ответа. Эти команды могут нести или не нести полезную нагрузку в зависимости от выполняемой операции. В случае операции сохранения изображения CT передается, например, команда DIMSE (C-STORE-RQ), за которой следует изображение CT (в форме IOD); затем удаленная сторона может ответить командой DIMSE (C-STORE-RSP), которая предоставит статус операции. Для операции проверки нет других данных, кроме посылки самой команды ("C-ECHO-RQ" в данном случае), а удаленная сторона может ответить обратно объектом "C-ECHO-RSP".

Также следует знать, что в отличие от других протоколов связи, хотя стандарт DICOM и не требует этого, информация, касающаяся обеих сторон, часто должна быть настроена на каждом объекте при настройке подключения DICOM. Так, например, если вы настраиваете новый сервер DICOM в своей сети, то вы должны не только настроить этот объект с его уникальным заголовком объекта приложения (АЕТ) в сети, но и также указать ему список других объектов, которые могут запрашивать услуги от него, чтобы он реагировал на них при общении. Давайте перейдем к рассмотрению примера кода, чтобы увидеть, как это работает в реальной жизни.

Пример проверки DICOM

Данный материал создан на основе творческого перевода статьи "DICOM Basics using .NET and C# - Understanding DICOM Verification", но далее я буду использовать собственный программный код для иллюстрации работы сервиса  проверки DICOM с помощью библиотеки fo-dicom для .NET и C#. За 10-15 минут мы получим полноценную программу для "прозвонки" dicom-устройств.

Набор инструментов DICOM Fellow Oak предоставляет ряд классов для выполнения сетевых операций DICOM. Эти классы можно найти в пространстве имен FellowOakDicom.Network библиотеки. Все операции DICOM SCU, включая проверку DICOM, выполняются с помощью класса DicomClientFactory . Класс предоставляет простые в использовании методы (как синхронные, так и асинхронные) для вызова ряда операций DICOM на любых удаленных узлах. В приведенном ниже коде я просто создаю экземпляр клиента dicom и добавляю к нему «эхо-запрос».
Итак, создаем в Visual Studio новый Windows Form проект.
На форме я разместил 5 подписей, 4 текстовых бокса и одну кнопку.

Текстовым полям здесь я сразу присвоил значения, чтобы не вводить их каждый раз при тестировании кода. Здесь указан реальный тестовый сервер https://dicomserver.co.uk/, с помощью которого можно протестировать работу своих приложений. Причем для этого сервера что вызывающий AET - Local AET, что вызываемый - Remote AET могут иметь любые значения. В поле Host Name можно вводить как название хоста, так и IP-адрес (для https://dicomserver.co.uk/ это 198.244.176.149), Dicom Port - только целое положительное число. Ещё раз: приведенные значения корректны только для этого сервера. Вообще, как правило, вызывающие заголовки должны быть зарегистрированы на сервере, а сочетание АЕТа, адреса и порта должны однозначно идентифицировать устройство (сервер PACS, например) в сети.
Вернемся к коду. При нажатии на кнопку "Echo" вызывается асинхронная процедура посылки запроса с аргументами, указанными в текстовых полях и ожидается результат в виде логического значения. Результат отображается в виде текста рядом с кнопкой - "Success" или "Failed".

try
            {
                labelRezultEcho.Text = "Wait...";
                labelRezultEcho.ForeColor = Color.Black;
                labelRezultEcho.Visible = true;
                if (await EchoRQ.EchoRSPAsync( textBoxLocalAet.Text,
                                               textBoxRemoteAet.Text,
                                               textBoxHostName.Text,
                                               int.Parse(textBoxDicomPort.Text)
                                              ))
                {
                    labelRezultEcho.Text = "Success !";
                    labelRezultEcho.ForeColor = Color.Green;
                }
                else
                {
                    labelRezultEcho.Text = "Failed !";
                    labelRezultEcho.ForeColor = Color.Red;
                }
            }
            catch (Exception ex)
            {
                MessageBox.Show($"Error: {ex.Message}");
            }

Процедуру посылки запроса я оформил в отдельном классе, код которого приводится ниже:

using FellowOakDicom.Network.Client;
using FellowOakDicom.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Net;
using System.Windows.Forms;

namespace DicomEcho
internal class EchoRQ
    {
        private static bool echoRezult = false;

        public static async Task EchoRSPAsync(string localAet, string remoteAet, string hostName, int dicomPort)
        {
            try
            {
                if (await EchoAsync(localAet, remoteAet, hostName, dicomPort))
                return echoRezult;
                else return false;
            }
            catch
            {
                return false;
            }
        }
        private static async Task  EchoAsync(string localAet, string remoteAet, string hostName, int dicomPort)
        {
            try
            {   //Создание клиента dicom             
                var client = DicomClientFactory.Create(hostName, dicomPort, false, localAet, remoteAet);
                client.NegotiateAsyncOps();
                var requestEcho = new DicomCEchoRequest(); // сохдание экземпляра запроса "эха"
                requestEcho.OnResponseReceived += EchoResponse; // Назначение процедуры обработки ответа на запрос
                await client.AddRequestAsync(requestEcho).ConfigureAwait(true); //Подключение запроса
                await client.SendAsync().ConfigureAwait(true); //Собственно отправка запроса
                return true;
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message);
                return false;
            }
        }

        private static void EchoResponse(DicomCEchoRequest request, DicomCEchoResponse response)
        {
            if (response.Status.ToString() == "Success")
            {
                echoRezult = true;
                return;
            }
            else
            {
                echoRezult = false;
            }
        }
    }
}

Подключить библиотеку fo-dicom можно просто через меню управления пакетами NuGet

Собираем, пользуемся 🙂

 

Основы DICOM с использованием .NET и C#: понимание файла DICOM

Файл DICOM содержит изображения (или другие данные) и данные о пациенте  с использованием концепции тегов, которая будет объяснена позже. Информация, хранящаяся в файле DICOM, гораздо более структурирована и разнообразна, чем в других стандартах хранения изображений, с помощью словаря тегов DICOM. Этот словарь, содержащий несколько тысяч таких тегов, помогает кодировать такую ​​информацию, как, например, когда и где было сделано изображение, кому оно принадлежит, ФИО направившего врача и т.д., и сохранять её вместе с пиксельными данными изображения. Это сводит к минимуму риск приписать важную медицинскую информацию не тому пациенту.

Большинство файлов DICOM обычно содержат данные одного изображения, а иногда могут содержать несколько изображений (или « кадров», как их часто называют в DICOM), чтобы обеспечить то, что называется "кинопетлей" , которая позволяет просмотрщику DICOM визуализировать всю последовательность изображений в виде фильма. Однако файлы DICOM не обязательно должны содержать изображения, как предполагает большинство людей, а могут также использоваться для хранения другой информации, такой как текстовые отчеты, сигналы ЭКГ и даже аудио или видео.

Заглянем внутрь файла DICOM

Каждый файл DICOM состоит из трех основных частей.

Первая часть - заголовок файла - состоит из 128-байтовой преамбулы файла, за которой следует 4-байтовый префикс. Этот подход очень распространен во многих других стандартах изображений, таких как TIFF, который вы, возможно, уже видели/использовали. Префикс длиной 4 байта состоит из символов верхнего регистра «DICM» (обратите внимание, это не «DICOM», а «DICM»). Стандарт не заботится о том, как должна быть структурирована преамбула и что в ней хранить. Насколько я понимаю, использование преамбулы файла предназначено просто для обеспечения совместимости или согласованности приложения обработки при работе с файлами DICOM, как и с некоторыми другими существующими форматами файлов изображений. Стандарт не заботится о том, что вы в нем храните или как вы его используете. Таким образом, теоретически ваше приложение может полностью пропустить эти данные при анализе файла DICOM, если вы захотите.

Прежде чем мы рассмотрим следующую часть файла DICOM, необходимо сказать кое-что о концепции синтаксиса передачи и его роли в стандарте DICOM. Стандарт DICOM позволяет устройствам передавать информацию друг другу, даже если они работают на разных операционных системах. Различные операционные системы и устройства используют разные форматы хранения данных, например, порядок байтов при хранении двоичных данных. Из-за высоких требований к сети для обмена большими изображениями, полученными с помощью таких методов сканирования, как КТ или МРТ, в стандарте также предусмотрены положения для обмена данными изображений с использованием сжатия, когда это необходимо. Все три критерия (тип кодирования VR, тип порядка байтов и используемое сжатие) должны быть сначала поняты и согласованы, чтобы гарантировать, что две системы DICOM, обменивающиеся информацией, понимают друг друга во время любого взаимодействия между ними. Синтаксис передачи — это набор правил кодирования, который указывается посредством использования специальных уникальных идентификаторов - UID . Например, Implicit VR с прямым порядком байтов обозначается значением UID 1.2.840.10008.1.2), Explicit VR с прямым порядком байтов — 1.2.840.10008.1.2.1,  JPEG Lossless — 1.2.840.10008.1.2.4.57 — это некоторые из синтаксисов передачи, доступных для обработки DICOM.

Теперь, когда мы вкратце понимаем, что такое синтаксис передачи, давайте посмотрим на следующую часть файла, а именно на заголовок метаинформации файла . Этот раздел следует сразу после заголовка файла и состоит из набора данных, состоящего из последовательности тегированной информации (называемой «Элементы Dicom» ), которая определяет такие детали, как синтаксис передачи (поясняется выше), а также другую информацию, касающуюся устройства или реализации, кто создал этот файл и для кого была создана эта информация (приложение-получатель) (см. подробную информацию на стр. 32 данного документа )

За этим разделом следует третья и последняя часть файла DICOM, представляющая собой объект данных. Эта часть файла DICOM также определяется в виде набора данных, состоящего из ряда тегов, которые, в свою очередь, могут быть вложенными и сами нести дополнительные дочерние теги. Эти теги несут информацию об исследование, серии, пациенте, которому они принадлежат, а также другие сведения об изображении и т. д. Исследование, серии и информация о пациенте часто используются для индексации изображения в большинстве систем PACS для более быстрого поиска данных. Иллюстрация ниже дает краткий обзор общей структуры файла, а также показывает, что отдельные элементы DICOM (каждый элемент включает в себя тег и связанную с ним информацию) являются частью всей структуры.

Например, в первом из трех элементов DICOM в разделе объекта данных, показанном на этой иллюстрации, «(0008, 0070)» указывает на тег, принадлежащий группе с номером 0008 с номером атрибута 0070, «LO» указывает тип данных или представление значения (VR) , как его называет DICOM (LO относится к типу данных Long String, см. Типы данных в DICOM), «PHILIPS» — это фактическое значение тега, «#8» указывает длину значения - 8 (Обратите внимание, что DICOM всегда кодирует данные, используя четное количество символов для текста, поэтому используется дополнительный символ заполнения, даже если длина значения PHILIPS составляет всего 7 символов), 1 представляет здесь кратность значения (некоторые данные могут быть повторяется), а «Manufacturer» — это фактическое имя тега, указанное в словаре DICOM. Номер группы и атрибута, VR, значение, кратность значения и имя тега вместе называются элементом DICOM . Поскольку словарь DICOM (см. стр. 23 и далее) неявно определяет VR, связанный с каждым тегом, то VR является избыточным и иногда опускается. Несмотря на это, общепринятой практикой и рекомендацией является явное указание VR при сериализации объектов DICOM в файлы или при обмене информацией DICOM по сети. Теперь мы можем лучше понять, что такое IOD (Определение информационного объекта)

Что на самом деле означают эти термины в DICOM — SCU, SCP, SOP и IOD?

DICOM определяет концепции сервисов и данных, которые эти сервисы используют или на основе которых действуют. Примером службы может быть служба CT Store, которая отвечает за сохранение изображения, сгенерированного с помощью модальности CT, на сервере PACS. Служба состоит из двух частей: потребителя службы, также известного как пользователь класса обслуживания или SCU , и поставщика службы, также известного как поставщик класса обслуживания или SCP . Например, в операции хранилища CT модальность, которая генерирует изображение, действует как SCU C-Store и передает данные для хранения в C-Store SCP, которым является сервер PACS. В стандарте DICOM комбинация классов обслуживания и объектов, связанных с этими услугами, известна как пара объектов обслуживания или SOP. Абстрактное определение SOP называется классом SOP, и они определяются уникальными идентификаторами (называемыми UID ). Таким образом, хранилище изображений CT SOP, которое идентифицируется UID класса SOP 1.2.840.10008.5.1.4.1.1.2, помогает определить, что это операция хранения изображений CT. В ходе этой операции между участвующими машинами происходит обмен командами (называемый DIMSE), а также некоторыми данными, которые включают в себя информацию о пикселях изображения и другую идентификационную информацию о пациенте, исследовании, серии и оборудовании. Вместе эти конкретные детали этой операции известны как экземпляр SOP . Каждый из этих экземпляров SOP также идентифицируется уникальным идентификатором, и генерируется приложением, ответственным за их передачу. Эти идентификаторы называются UID экземпляра SOP . Фактические данные, используемые в этой паре SOP, называются IOD (Определением информационного объекта), определяющим, какие модули DICOM (модули, по сути, представляют собой группы элементов DICOM) должны присутствовать для успешного завершения обработки.

Объекты IOD сами разбиты на подгруппы, называемые информационными объектами (сокращенно IE), а информационные объекты, в свою очередь, разбиваются на небольшие группы информационных модулей . Информационные модули состоят из ряда элементов DICOM , которые мы уже видели. DICOM определяет правила относительно того, какие модули являются обязательными, какие присутствуют условно, а какие необязательными. Сами IOD подразделяются на нормализованные IOD и составные IOD . Нормализованные IOD представляют данные, относящиеся только к одному объекту, тогда как составные IOD представляют данные из смеси различных объектов, связанных друг с другом, как показано на иллюстрации ниже. По сути, это общая структура информационной модели DICOM. Объединив все это, вы теперь увидите, что любой файл DICOM, с которым мы до сих пор имели дело, на самом деле является экземпляром IOD (сериализованной версии информации), который также передается между двумя машинами во время любого рабочего процесса обработки изображений. А в рассматриваемом нами случае — операция, помогающая сохранить изображение, созданное с помощью метода КТ, на сервере PACS. IOD и кодирование DICOM — это нечто большее, чем то, что описано здесь, но мы будем иметь дело с этими областями при обсуждении создания файлов и каталогов DICOM.

Fellow Oak (fo-dicom) Набор инструментов DICOM — краткий обзор

В целях иллюстрации многих аспектов DICOM, которые я планирую осветить, я буду использовать свободно доступный и мощный набор инструментов DICOM под названием fo-dicom DICOM Toolkit . Это полностью автономный набор инструментов DICOM, который реализует такие функции, как обработка файлов и каталогов DICOM, а также операции, связанные с сетью DICOM. Этот набор инструментов совершенно бесплатен как для коммерческого, так и для некоммерческого использования. Использование этого набора инструментов в моем руководстве ни в коем случае не означает моего официального одобрения его для реализации производственного приложения. Каждая ситуация уникальна, и только вы в конечном итоге можете ее решить. Эта статья также не является учебным пособием по набору инструментов Fellow Oak DICOM, и моя цель здесь — просто связать теорию DICOM с тем, как может выглядеть практическая (хотя и простая) реализация. Если ваша цель — научиться использовать библиотеку DICOM Fellow Oak, я бы посоветовал вам посетить ее веб-сайт   для получения более подробной информации. Библиотека используется для разработки на языке программирования C#, поэтому для опробования кода удобно будет использовать Visual Studio от Microsoft. Подключение библиотеки к проекту не представляет труда, т.к. существует ее nuget-пакет, который можно найти и установить прямо из интерфейса студии.

Вывод определенных тегов файла DICOM на консоль

На иллюстрации кода показан простой способ составить список некоторых основных часто используемых тегов DICOM. В этом наборе инструментов имеется ряд перегрузок и удобных методов извлечения тегов DICOM.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using FellowOakDicom;

    namespace MakingSenseOfDicomFile
    {
      public class Program
      {
        private static readonly string PathToDicomTestFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Test Files", "0002.dcm");

        public static void Main(string[] args)
        {
          try
          {
            LogToDebugConsole($"Attempting to extract information from DICOM file:{PathToDicomTestFile}...");

            var file = DicomFile.Open(PathToDicomTestFile,readOption:FileReadOption.ReadAll);
            var dicomDataset = file.Dataset;
            var studyInstanceUid = dicomDataset.GetSingleValue(DicomTag.StudyInstanceUID);
            var seriesInstanceUid = dicomDataset.GetSingleValue(DicomTag.SeriesInstanceUID);
            var sopClassUid = dicomDataset.GetSingleValue(DicomTag.SOPClassUID);
            var sopInstanceUid = dicomDataset.GetSingleValue(DicomTag.SOPInstanceUID);
            var transferSyntaxUid = file.FileMetaInfo.TransferSyntax;

            LogToDebugConsole($" StudyInstanceUid - {studyInstanceUid}");
            LogToDebugConsole($" SeriesInstanceUid - {seriesInstanceUid}");
            LogToDebugConsole($" SopClassUid - {sopClassUid}");
            LogToDebugConsole($" SopInstanceUid - {sopInstanceUid}");
            LogToDebugConsole($" TransferSyntaxUid - {transferSyntaxUid}");

            LogToDebugConsole($"Extract operation from DICOM file successful. Press Enter for continue...");
            Console.ReadLine();
          }
          catch (Exception e)
          {
            LogToDebugConsole($"Error occured during DICOM file dump operation -> {e.StackTrace}");
          }
      }

      private static void LogToDebugConsole(string informationToLog)
      {
          Debug.WriteLine(informationToLog);
      }
  }
}

Результат выполнения приведенного выше кода показан ниже:

Вывод всех тегов файла DICOM на консоль

Иногда нужно получить все теги, содержащиеся в файле DICOM, включая номер группы и атрибута, представление значения (VR), значение, длину значения, кратность значения и информацию об имени тега. Этот легко достигается путем перебора набора данных DICOM, что иллюстрирует приведенный ниже код.

try
 {
   LogToDebugConsole($"Attempting to extract information from DICOM file:{PathToDicomTestFile}...");
   var file = DicomFile.Open(PathToDicomTestFile);

   foreach (var tag in file.Dataset)
   {
      LogToDebugConsole($" {tag} '{file.Dataset.GetValueOrDefault(tag.Tag, 0, "")}'");
   }
   LogToDebugConsole($"Extract operation from DICOM file successful. Press Enter for close console");
   Console.ReadLine();
 }
 catch (Exception e)
 {
   LogToDebugConsole($"Error occured during DICOM file dump operation -> {e.StackTrace}");
 }

Результат выполнения:

Данный материал является моим (Александр Кузнецов) творческим переводом статьи "DICOM Basics using .NET and C# - Making Sense of the DICOM File", автором которой является Saravanan Subramanian. Приведенные примеры кода основаны на первоисточнике, но переработаны мной в связи с устареванием приведенных определений используемой библиотеки.

 

 

Dicom AutoExport 1.9.4

Программа Dicom AutoExport предназначена для:

  • обработки рентгеновских изображений в формате dicom (расширения файлов .dcm, .dic или без расширения), которые были созданы программами получения рентгеновских снимков*, с целью устранения ошибок совместимости примененных кириллических кодировок (в основном win1251) со стандартом Dicom 3.0;
  • добавления/удаления/редактирования «на лету» элементов (dicom-тегов);
  • сохранения обработанного файла в заданной папке экспорта;
  • отправки обработанных изображений на стандартный dicom-сервер, возможна отправка и исходных файлов без обработки, если они соответствуют стандарту.

Все перечисленные операции выполняются автоматически в фоновом режиме.

*Примечание. Возможна обработка не только рентгеновских изображений, но и других файлов, имеющих формат dicom, например, результаты исследований УЗИ, ЭКГ, ангиографии, томографии, маммографии и т.п.

При запуске Программа сворачивает свое окно в область задач или область уведомлений ("трей") и “наблюдает” за заданной папкой. При появлении в ней нового dicom-файла снимка или переименовании старого файла уже находящегося в ней (начиная с версии 1.9.4), последний копируется в рабочую папку Программы и обрабатывается. При успешности обработки снимок отправляется на заданный dicom-сервер.

Поводом для очередного обновления программы стала необходимость изменения некоторых функций по пожеланиям пользователей :).

Что нового в версии 1.9.4?

Внесено одно существенное изменение.  Теперь программа мониторит не только появление новых файлов в папке, но и отслеживает событие переименования файла уже находящегося в этой папке. Причина: При попытке использования программы (для транслитерации и отправки файлов на сервер) на одной из систем с ПО "ИнтегРИС" было обнаружено, что система после получения снимка помещает в папку экспорта исследования временный dicom-файл с расширением .tmp, затем, видимо, проводит с ним какие-то действия, а затем переименовывает его с расширением .dcm. Т.е. файл остается тот же, операционная система не генерирует событие сохранения нового файла, а выдает сообщения о событиях переименования (их два на самом деле). Поэтому пришлось добавить в программу обработку такой ситуации. Ранее с таким случаем не сталкивался. Чаще системы временный файл копируют в новый файл, а временный затем удаляют. А чтобы программа не "хватала" временный файл и не пыталась его обработать, нужно в ини-файле добавить соответствующее раcширение в список игнорируемых.

Добавлена опция  выбора правила транслитерации. Причина: правила транслитерации для русского и украинского языков устанавливаются нормативными документами и, конечно же, различаются. Поэтому стало необходимым  добавить возможность выбора конкретного правила.

По ходу решения вопроса выбора правила транслитерации обнаружилась проблема с некорректной обработкой текстов с двойными кавычками именно при транслитерации. Для других видов обработки она уже решалась ранее. Вообще, двойные кавычки допускаются стандартом, но используемые утилиты dcmtk обработки работают с ними некорректно.

Инструкция Dicom AutoExport 1.9.4

Здесь можете написать автору свои вопросы, предложения, замечания:

Для заполнения данной формы включите JavaScript в браузере.

 

Что такое Заявление о соответствии стандарту DICOM?

Некоторые поставщики и заказчики медицинской аппаратуры, заявляя в своей документации о соответствии стандарту Dicom, не понимают о чем, собственно заявляют, как собственно необходимо "заявлять" и что такое вообще "Заявление о соответствии стандарту Dicom". Краткий ликбез на эту тему.

Перевод статьи Bimba Shrestha с исправлениями и дополнениями.

Стандарт DICOM сложен, и разные медицинские устройства поддерживают его в разной степени. Например, компьютерный томограф может поддерживать определенный набор функций DICOM, а архив изображений (PACS) может поддерживать другой. Оба устройства совместимы с DICOM, но если они не поддерживают определенные дополнительные функции, они не будут работать вместе.Что это означает для покупателей, продавцов и пользователей медицинского оборудования? Это означает, что не каждое DICOM-совместимое устройство обязательно будет взаимодействовать с другим DICOM-совместимым устройством!Здесь вступает в действие Заявление о соответствии стандарту DICOM ( DICOM Conformance Statement — DCS). DCS — это подробный технический ДОКУМЕНТ, сопровождающий большинство устройств, в котором точно указывается КАКИЕ конкретно функции стандарта поддерживаются. Возвращаясь к нашему примеру, в Заявлении о соответствии стандарту КТ-сканера должно быть указано, что он поддерживает функции, перечисленные как требуемые в Заявлении о соответствии стандарту архива изображений, чтобы они могли работать вместе.
SOP, SCU и SCP
DCS используют множество специфических терминов. Некоторыми распространенными из них являются SOP (пара объектов службы), SCU (пользователь класса службы) и SCP (поставщик класса службы). Эти термины имеют очень специфические значения в стандарте DICOM, но на данный момент вы можете думать о SOP как о «некоторой желательной функции устройства», а SCU/SCP — как о дополнительных частях этой функции. Половина SCP обеспечивает функцию, а половина SCU использует ее.Чтобы продолжить наш предыдущий пример: чтобы компьютерный томограф мог отправлять изображения в архив изображений он должен быть SCU для SOP «Хранение изображений CT», а архив изображений должен быть SCP для него. Если обе половины этой функции отсутствуют, два устройства не будут работать вместе. Даже если они оба помечены как совместимые с DICOM!DCS (среди прочего) — это список поддерживаемых SOP, определенных как роли SCU/SCP. Сравнив этот список между устройствами, вы сможете определить их совместимость.Также смотрите ликбез системному администратору на эту тему.
Структура DCS
Несмотря на сложность, большинство DCS имеют одинаковую структуру документов. Это сделано намеренно, и разработчики стандарта DICOM приложили большие усилия, чтобы предоставить подробные шаблоны для DCS. Вот высокоуровневая структура DCS (подробности смотрите в официальном стандарте ) :
  • Титульная страница : первая страница, содержащая название устройства и дату.
  • Обзор заявления о соответствии : описание устройства непрофессионалом.
  • Оглавление
  • Введение : общая информация, соответствующие отказы от ответственности и условия.
  • Сеть : Описание услуг, связанных с сетью.
  • Медиа-обмен : Описание несетевых служб обмена.
  • Поддержка наборов символов : какие наборы символов и кодировки поддерживаются (подробнее о проблемах кириллических кодировок см. в описании программы Dicom AutoExport).
  • Безопасность : Соответствующие вопросы безопасности, описания и заявления об отказе от ответственности.
  • Приложения
Для покупателей медицинского оборудования
Если вы являетесь покупателем, оценивающим медицинское устройство, вам всегда следует запрашивать DCS. Как упоминалось выше, то, что устройство совместимо с DICOM, ещё не означает, что оно обязательно будет работать с вашими существующими информационными системами. Например, вот несколько случаев, в которых что-то может пойти не так, если вы не изучите подробно DCS поставщика:

  • Если вы покупаете устройство, которое не поддерживает SOP SCP для проверки DICOM, вы никогда не сможете проверить подключение DICOM к этому устройству с другого устройства DICOM. Сообщения с подтверждением, отправленные на него, останутся без ответа, как будто устройство либо не существует, либо имеет разорванное сетевое соединение.
  • Если вы покупаете архив, который поддерживает SOP SCP хранилища MR, это не означает, что он должен поддерживать SOP SCP хранилища CT, и вы, возможно, не сможете перенести свои данные CT в тот же архив.
  • Если у вас есть рабочая станция, которая поддерживает C-Find (Query) SCU, но не поддерживает C-Find (Query) SCP, вы никогда не сможете запросить эту рабочую станцию ​​с другой рабочей станции или из архива. Только SCP-запросы могут отвечать на запросы, передавая эти ответы другим устройствам.
  • Если вы покупаете телерадиологический сервер DICOM, который не поддерживает сжатие изображений для передачи изображений C-Store, вы не сможете отправлять сжатые изображения. И т. д. ...

Список потенциальных проблем длинный, поэтому будьте внимательны и всегда просите DCS.

Для продавцов медицинского оборудования
Если вы являетесь продавцом медицинского оборудования, в ваших же интересах поставлять свой продукт с сопроводительной DCS. Это единственный способ, которым ваши потенциальные клиенты могут оценить совместимость вашего устройства с их существующей настройкой.

 Пример Заявления о соответствии:

Сервер XrayPACS. Заява про відповідність стандарту Dicom

 

Scroll to top