DICOM Basics using .NET and C# - Query and Retrieve Operations (C-MOVE)

Introduction

This is part of my series of articles on the DICOM standard. In this tutorial, we'll explore the C-MOVE composite service, which is used to retrieve DICOM data from a remote server. Before diving in, please review my earlier tutorials on DICOM Verification, DICOM Associations, and C-FIND query operations as a basic understanding of those topics is required.

C-MOVE is the most popular method for retrieving DICOM files in clinical settings. Despite its somewhat counter-intuitive name (you're actually retrieving data, not moving it), the operation can serve two purposes: retrieving data to the caller, or directing a remote server to send data to an entirely different destination.

Prerequisites

Before you begin, ensure you have the following:

“Life can only be understood backwards; but it must be lived forwards.” ~ Søren Kierkegaard

How C-MOVE Works

Understanding C-MOVE requires knowing that it actually uses C-STORE as a "sub-operation" under the covers:

  1. C-MOVE SCU sends a C-MOVE request to the C-MOVE SCP (PACS server)
  2. The C-MOVE SCP initiates a new association as a C-STORE SCU
  3. The C-MOVE SCP pushes data to the destination (which must have a C-STORE SCP running)
  4. The destination is often the original C-MOVE SCU, but can be a different system
  5. Progress status is communicated back to the C-MOVE SCU

This means your application needs to:

  • Run a C-STORE SCP to receive incoming images
  • Be registered in the PACS server's AE title configuration
  • Have the appropriate ports open for incoming connections

C-MOVE vs C-GET

AspectC-MOVEC-GET
AssociationsTwo (separate for store)One (same association)
Can send to third partyYesNo
Firewall complexityHigher (needs incoming port)Lower
Server supportUniversalLess common historically
ConfigurationMore complexSimpler

Step 1 of 3: Setting Up the C-STORE SCP

First, we need a C-STORE SCP running to receive the images pushed by the PACS server:

using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using FellowOakDicom;
using System.Threading;
using System.Threading.Tasks;
using FellowOakDicom.Network;
using System.Threading;
using System.Threading.Tasks;
using FellowOakDicom.Network.Client;

namespace DicomCMoveExample
{
    public class Program
    {
        // Configuration
        private static readonly string PacsServerHost = "localhost";
        private static readonly int PacsServerPort = 4242;
        private static readonly string PacsAeTitle = "ORTHANC";

        private static readonly string LocalAeTitle = "FODICOM_MOVE";
        private static readonly int LocalStoreScpPort = 11113;

        private static readonly string OutputDirectory =
            Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "RetrievedImages");

        public static async Task Main(string[] args)
        {
            try
            {
                LogToDebugConsole("=== DICOM C-MOVE Tutorial ===");

                // Ensure output directory exists
                if (!Directory.Exists(OutputDirectory))
                {
                    Directory.CreateDirectory(OutputDirectory);
                }

                // Step 1: Start C-STORE SCP to receive images
                LogToDebugConsole($"Starting C-STORE SCP on port {LocalStoreScpPort}...");
                var storeServer = StartStoreScp();

                // Step 2: Query for studies (C-FIND)
                LogToDebugConsole("Querying for studies...");
                var studyUid = await FindStudyAsync();

                if (!string.IsNullOrEmpty(studyUid))
                {
                    // Step 3: Retrieve the study (C-MOVE)
                    LogToDebugConsole($"Retrieving study: {studyUid}");
                    await MoveStudyAsync(studyUid);
                }

                LogToDebugConsole("C-MOVE operation completed.");
                LogToDebugConsole($"Retrieved files saved to: {OutputDirectory}");
            }
            catch (Exception e)
            {
                LogToDebugConsole($"Error: {e.Message}");
            }
        }

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

Step 2 of 3: Implementing the C-STORE SCP

The C-STORE SCP handles incoming images from the PACS server:

private static IDicomServer StartStoreScp()
{
    var server = DicomServer.Create<CStoreScp>(LocalStoreScpPort);
    LogToDebugConsole($"  C-STORE SCP listening on port {LocalStoreScpPort}");
    LogToDebugConsole($"  AE Title: {LocalAeTitle}");
    return server;
}

/// <summary>
/// C-STORE SCP implementation to receive images from C-MOVE operation.
/// </summary>
public class CStoreScp : DicomService, IDicomServiceProvider, IDicomCStoreProvider
{
    private static readonly string OutputDir =
        Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "RetrievedImages");

    public CStoreScp(INetworkStream stream, Encoding fallbackEncoding, Logger log)
        : base(stream, fallbackEncoding, log)
    {
    }

    public void OnReceiveAssociationRequest(DicomAssociation association)
    {
        Debug.WriteLine($"Received association from: {association.CallingAE}");

        foreach (var pc in association.PresentationContexts)
        {
            // Accept all storage SOP classes
            if (pc.AbstractSyntax.StorageCategory != DicomStorageCategory.None)
            {
                pc.AcceptTransferSyntaxes(pc.GetTransferSyntaxes().ToArray());
            }
        }

        SendAssociationAccept(association);
    }

    public void OnReceiveAssociationReleaseRequest()
    {
        SendAssociationReleaseResponse();
    }

    public void OnReceiveAbort(DicomAbortSource source, DicomAbortReason reason)
    {
        Debug.WriteLine($"Association aborted: {reason}");
    }

    public void OnConnectionClosed(Exception exception)
    {
        Debug.WriteLine("Connection closed");
    }

    public DicomCStoreResponse OnCStoreRequest(DicomCStoreRequest request)
    {
        // Save the received DICOM file
        var fileName = Path.Combine(OutputDir, $"{request.SOPInstanceUID.UID}.dcm");
        request.File.Save(fileName);

        Debug.WriteLine($"  Received: {request.SOPInstanceUID.UID}");
        Debug.WriteLine($"  Saved to: {fileName}");

        return new DicomCStoreResponse(request, DicomStatus.Success);
    }

    public void OnCStoreRequestException(string tempFileName, Exception e)
    {
        Debug.WriteLine($"C-STORE error: {e.Message}");
    }
}

Step 3 of 3: Implementing the C-MOVE SCU

Now we can implement the C-MOVE request:

private static async Task<string> FindStudyAsync()
{
    string foundStudyUid = null;

    var client = DicomClientFactory.Create(DicomServerHost, DicomServerPort, UseTls, LocalAeTitle, RemoteAeTitle);

    var findRequest = DicomCFindRequest.CreateStudyQuery(patientName: "*");
    findRequest.Dataset.AddOrUpdate(DicomTag.StudyDate, "");
    findRequest.Dataset.AddOrUpdate(DicomTag.StudyDescription, "");

    findRequest.OnResponseReceived += (request, response) =>
    {
        if (response.Status == DicomStatus.Pending && response.Dataset != null)
        {
            var studyUid = response.Dataset.GetSingleValueOrDefault(
                DicomTag.StudyInstanceUID, "");
            var patientName = response.Dataset.GetSingleValueOrDefault(
                DicomTag.PatientName, "");
            var studyDate = response.Dataset.GetSingleValueOrDefault(
                DicomTag.StudyDate, "");

            LogToDebugConsole($"  Found: {patientName} - {studyDate}");
            LogToDebugConsole($"    Study UID: {studyUid}");

            // Store the first study found
            if (string.IsNullOrEmpty(foundStudyUid))
            {
                foundStudyUid = studyUid;
            }
        }
    };

    await client.AddRequestAsync(findRequest);

    await client.SendAsync(PacsServerHost, PacsServerPort, false,
        LocalAeTitle, PacsAeTitle);

    return foundStudyUid;
}

private static async Task MoveStudyAsync(string studyInstanceUid)
{
    var client = DicomClientFactory.Create(DicomServerHost, DicomServerPort, UseTls, LocalAeTitle, RemoteAeTitle);

    // Create C-MOVE request at STUDY level
    var moveRequest = new DicomCMoveRequest(LocalAeTitle, studyInstanceUid);

    // Track progress
    int completed = 0;
    int remaining = 0;
    int failed = 0;

    moveRequest.OnResponseReceived += (request, response) =>
    {
        if (response.Status == DicomStatus.Pending)
        {
            // Update progress from sub-operations
            completed = response.Dataset?.GetSingleValueOrDefault(
                DicomTag.NumberOfCompletedSuboperations, 0) ?? 0;
            remaining = response.Dataset?.GetSingleValueOrDefault(
                DicomTag.NumberOfRemainingSuboperations, 0) ?? 0;
            failed = response.Dataset?.GetSingleValueOrDefault(
                DicomTag.NumberOfFailedSuboperations, 0) ?? 0;

            LogToDebugConsole($"  Progress: {completed} completed, {remaining} remaining, {failed} failed");
        }
        else if (response.Status == DicomStatus.Success)
        {
            LogToDebugConsole($"  C-MOVE completed successfully!");
            LogToDebugConsole($"  Total images retrieved: {completed}");
        }
        else
        {
            LogToDebugConsole($"  C-MOVE status: {response.Status}");
        }
    };

    await client.AddRequestAsync(moveRequest);

    LogToDebugConsole($"  Sending C-MOVE request...");
    LogToDebugConsole($"  Destination AE: {LocalAeTitle}");

    await client.SendAsync(PacsServerHost, PacsServerPort, false,
        LocalAeTitle, PacsAeTitle);
}

Sample output:

=== DICOM C-MOVE Tutorial ===
Starting C-STORE SCP on port 11113...
  C-STORE SCP listening on port 11113
  AE Title: FODICOM_MOVE
Querying for studies...
  Found: Smith^John - 20250115
    Study UID: 1.2.840.113619.2.55.3.12345
Retrieving study: 1.2.840.113619.2.55.3.12345
  Sending C-MOVE request...
  Destination AE: FODICOM_MOVE
Received association from: ORTHANC
  Received: 1.2.840.113619.2.55.3.12345.1
  Saved to: C:\...\RetrievedImages\1.2.840.113619.2.55.3.12345.1.dcm
  Progress: 1 completed, 9 remaining, 0 failed
  ...
  Progress: 10 completed, 0 remaining, 0 failed
  C-MOVE completed successfully!
  Total images retrieved: 10
C-MOVE operation completed.
Retrieved files saved to: C:\...\RetrievedImages

Orthanc Configuration

For C-MOVE to work, Orthanc must know about your C-STORE SCP. Add this to your Orthanc configuration:

{
  "DicomModalities": {
    "FODICOM_MOVE": ["FODICOM_MOVE", "localhost", 11113]
  }
}

Moving to a Different Destination

To send data to a different destination, simply change the destination AE title in the C-MOVE request:

// Send to a different destination instead of self
var moveRequest = new DicomCMoveRequest("OTHER_PACS", studyInstanceUid);

The remote PACS server (Orthanc) will push the data to "OTHER_PACS" instead of back to your application.

Important Considerations

  • AE Title Registration: The destination must be registered in the PACS server
  • Firewall: Port must be open for incoming C-STORE connections
  • Transfer Syntaxes: Your C-STORE SCP must accept the syntaxes the PACS uses
  • Storage Space: Ensure sufficient disk space for retrieved images

Conclusion

C-MOVE is the traditional and most widely supported method for retrieving DICOM data from PACS servers. While it requires running a C-STORE SCP and proper AE title configuration, it provides the flexibility to retrieve data directly or route it to other destinations.

Please check out the next tutorial in this series where we cover DICOM C-GET query/retrieve operations.