Введение
В этом материале я собираюсь начать исследование аспектов сети 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 обратно клиенту или в указанное место.