26  fo-dicom

I’ll walk you through implementing DICOM operations using fo-dicom (Fellow Oak DICOM), which is one of the most popular and well-maintained DICOM libraries for .NET. These examples will give you practical, working code that you can use to build DICOM applications.

26.1 Setting Up fo-dicom

First, install the NuGet package:

<PackageReference Include="fo-dicom" Version="5.1.2" />
<PackageReference Include="fo-dicom.NetCore" Version="5.1.2" />

26.2 C-ECHO: Connectivity Verification

C-ECHO is the simplest operation - perfect for testing if your DICOM connection works:

using FellowOakDicom.Network;
using FellowOakDicom.Network.Client;

public async Task<bool> PerformEchoAsync(string serverHost, int serverPort, 
    string serverAeTitle, string clientAeTitle)
{
    try
    {
        var client = DicomClientFactory.Create(serverHost, serverPort, false, 
            clientAeTitle, serverAeTitle);
        
        // Add C-ECHO request
        var request = new DicomCEchoRequest();
        
        // Track the response
        DicomCEchoResponse echoResponse = null;
        request.OnResponseReceived += (req, response) =>
        {
            echoResponse = response;
            Console.WriteLine($"C-ECHO Response Status: {response.Status}");
        };
        
        await client.AddRequestAsync(request);
        await client.SendAsync();
        
        return echoResponse?.Status == DicomStatus.Success;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"C-ECHO failed: {ex.Message}");
        return false;
    }
}

// Usage
bool isAlive = await PerformEchoAsync("192.168.1.100", 104, 
    "PACS_SERVER", "MY_WORKSTATION");

26.3 C-STORE SCU: Sending Images

Here’s how to send DICOM files to a PACS server:

using FellowOakDicom;
using FellowOakDicom.Network;
using FellowOakDicom.Network.Client;

public async Task SendDicomFileAsync(string filePath, string serverHost, 
    int serverPort, string serverAeTitle, string clientAeTitle)
{
    try
    {
        // Load the DICOM file
        var file = await DicomFile.OpenAsync(filePath);
        
        // Create client
        var client = DicomClientFactory.Create(serverHost, serverPort, false, 
            clientAeTitle, serverAeTitle);
        
        // Create C-STORE request
        var request = new DicomCStoreRequest(file);
        
        // Handle response
        request.OnResponseReceived += (req, response) =>
        {
            if (response.Status == DicomStatus.Success)
            {
                Console.WriteLine($"Successfully stored: {file.Dataset.GetString(DicomTag.SOPInstanceUID)}");
            }
            else
            {
                Console.WriteLine($"Store failed with status: {response.Status}");
            }
        };
        
        await client.AddRequestAsync(request);
        await client.SendAsync();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"C-STORE failed: {ex.Message}");
    }
}

// Send multiple files in one association
public async Task SendMultipleDicomFilesAsync(List<string> filePaths, 
    string serverHost, int serverPort, string serverAeTitle, string clientAeTitle)
{
    var client = DicomClientFactory.Create(serverHost, serverPort, false, 
        clientAeTitle, serverAeTitle);
    
    foreach (var filePath in filePaths)
    {
        var file = await DicomFile.OpenAsync(filePath);
        var request = new DicomCStoreRequest(file)
        {
            OnResponseReceived = (req, response) =>
            {
                Console.WriteLine($"File {Path.GetFileName(filePath)}: {response.Status}");
            }
        };
        
        await client.AddRequestAsync(request);
    }
    
    await client.SendAsync();
}

26.4 C-STORE SCP: Receiving Images

Creating a DICOM server that can receive images:

using FellowOakDicom.Network;
using FellowOakDicom.Log;

public class StorageProvider : DicomService, IDicomServiceProvider, IDicomCStoreProvider
{
    private static readonly DicomTransferSyntax[] AcceptedTransferSyntaxes = new[]
    {
        DicomTransferSyntax.ExplicitVRLittleEndian,
        DicomTransferSyntax.ImplicitVRLittleEndian,
        DicomTransferSyntax.JPEGLSLossless,
        DicomTransferSyntax.JPEG2000Lossless,
        DicomTransferSyntax.JPEGProcess14
    };

    public StorageProvider(INetworkStream stream, Encoding fallbackEncoding, 
        ILogger logger, DicomServiceDependencies dependencies)
        : base(stream, fallbackEncoding, logger, dependencies)
    {
    }

    public async Task OnReceiveAssociationRequestAsync(DicomAssociation association)
    {
        // Accept all storage classes
        foreach (var pc in association.PresentationContexts)
        {
            if (pc.AbstractSyntax.StorageCategory != DicomStorageCategory.None)
            {
                association.AcceptTransferSyntaxes(pc, AcceptedTransferSyntaxes);
            }
        }

        await SendAssociationAcceptAsync(association);
    }

    public async Task<DicomCStoreResponse> OnCStoreRequestAsync(DicomCStoreRequest request)
    {
        try
        {
            // Save the file
            var studyUid = request.Dataset.GetString(DicomTag.StudyInstanceUID);
            var seriesUid = request.Dataset.GetString(DicomTag.SeriesInstanceUID);
            var sopUid = request.Dataset.GetString(DicomTag.SOPInstanceUID);
            
            // Create directory structure
            var path = Path.Combine("DicomStorage", studyUid, seriesUid);
            Directory.CreateDirectory(path);
            
            var filePath = Path.Combine(path, $"{sopUid}.dcm");
            await request.File.SaveAsync(filePath);
            
            Console.WriteLine($"Stored file: {filePath}");
            
            return new DicomCStoreResponse(request, DicomStatus.Success);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Store failed: {ex.Message}");
            return new DicomCStoreResponse(request, DicomStatus.ProcessingFailure);
        }
    }

    public async Task OnCStoreRequestExceptionAsync(string tempFileName, Exception e)
    {
        // Clean up temporary file
        if (File.Exists(tempFileName))
            File.Delete(tempFileName);
    }
}

// Start the storage server
public async Task StartStorageServerAsync(int port, string aeTitle)
{
    var server = DicomServerFactory.Create<StorageProvider>(port);
    server.Options.LogDimseDatasets = false;
    server.Options.LogDataPDUs = false;
    
    Console.WriteLine($"Storage SCP started on port {port} with AE Title: {aeTitle}");
    
    // Keep server running
    await Task.Delay(Timeout.Infinite);
}

26.5 C-FIND: Querying Studies

C-FIND lets you search for studies, series, or images:

public async Task<List<DicomDataset>> FindStudiesAsync(
    string patientId, DateTime? studyDate,
    string serverHost, int serverPort, 
    string serverAeTitle, string clientAeTitle)
{
    var client = DicomClientFactory.Create(serverHost, serverPort, false, 
        clientAeTitle, serverAeTitle);
    
    var results = new List<DicomDataset>();
    
    // Create C-FIND request for STUDY level
    var request = new DicomCFindRequest(DicomQueryRetrieveLevel.Study);
    
    // Add query parameters - these are the matching keys
    request.Dataset.AddOrUpdate(DicomTag.PatientID, patientId ?? string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.PatientName, string.Empty);
    
    // Study level attributes
    request.Dataset.AddOrUpdate(DicomTag.StudyInstanceUID, string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.StudyDescription, string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.StudyDate, 
        studyDate?.ToString("yyyyMMdd") ?? string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.StudyTime, string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.AccessionNumber, string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.Modality, string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.NumberOfStudyRelatedInstances, string.Empty);
    
    // Handle responses
    request.OnResponseReceived = (req, response) =>
    {
        if (response.Status == DicomStatus.Pending && response.HasDataset)
        {
            // Each pending response contains one match
            results.Add(response.Dataset);
            
            Console.WriteLine($"Found study: " +
                $"{response.Dataset.GetString(DicomTag.PatientName)} - " +
                $"{response.Dataset.GetString(DicomTag.StudyDescription)} - " +
                $"{response.Dataset.GetString(DicomTag.StudyDate)}");
        }
        else if (response.Status == DicomStatus.Success)
        {
            Console.WriteLine($"C-FIND completed. Found {results.Count} studies.");
        }
    };
    
    await client.AddRequestAsync(request);
    await client.SendAsync();
    
    return results;
}

// More complex query with date range
public async Task<List<DicomDataset>> FindStudiesWithDateRangeAsync(
    string modality, DateTime startDate, DateTime endDate,
    string serverHost, int serverPort, 
    string serverAeTitle, string clientAeTitle)
{
    var client = DicomClientFactory.Create(serverHost, serverPort, false, 
        clientAeTitle, serverAeTitle);
    
    var results = new List<DicomDataset>();
    
    var request = new DicomCFindRequest(DicomQueryRetrieveLevel.Study);
    
    // Query parameters
    request.Dataset.AddOrUpdate(DicomTag.PatientName, string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.PatientID, string.Empty);
    request.Dataset.AddOrUpdate(DicomTag.StudyInstanceUID, string.Empty);
    
    // Date range query (DICOM format: YYYYMMDD-YYYYMMDD)
    var dateRange = $"{startDate:yyyyMMdd}-{endDate:yyyyMMdd}";
    request.Dataset.AddOrUpdate(DicomTag.StudyDate, dateRange);
    
    // Modality (CT, MR, CR, etc.)
    request.Dataset.AddOrUpdate(DicomTag.ModalitiesInStudy, modality ?? string.Empty);
    
    request.OnResponseReceived = (req, response) =>
    {
        if (response.Status == DicomStatus.Pending && response.HasDataset)
        {
            results.Add(response.Dataset);
        }
    };
    
    await client.AddRequestAsync(request);
    await client.SendAsync();
    
    return results;
}

26.6 C-MOVE: Requesting Transfer to Another AE

C-MOVE tells the server to send images to a destination (which could be yourself):

public async Task MoveStudyAsync(
    string studyInstanceUid, string destinationAe,
    string serverHost, int serverPort,
    string serverAeTitle, string clientAeTitle)
{
    var client = DicomClientFactory.Create(serverHost, serverPort, false, 
        clientAeTitle, serverAeTitle);
    
    // Create C-MOVE request at STUDY level
    var request = new DicomCMoveRequest(destinationAe, studyInstanceUid);
    
    int completed = 0;
    int warnings = 0;
    int failures = 0;
    
    request.OnResponseReceived = (req, response) =>
    {
        if (response.Status.State == DicomState.Pending)
        {
            Console.WriteLine($"Moving... Completed: {response.Completed}/" +
                $"{response.Remaining + response.Completed}");
        }
        else if (response.Status.State == DicomState.Success)
        {
            Console.WriteLine($"C-MOVE completed successfully. " +
                $"Completed: {response.Completed}, " +
                $"Warnings: {response.Warnings}, " +
                $"Failures: {response.Failures}");
        }
        else
        {
            Console.WriteLine($"C-MOVE failed with status: {response.Status}");
        }
        
        completed = response.Completed ?? 0;
        warnings = response.Warnings ?? 0;
        failures = response.Failures ?? 0;
    };
    
    await client.AddRequestAsync(request);
    await client.SendAsync();
}

// Move at SERIES level
public async Task MoveSeriesAsync(
    string studyInstanceUid, string seriesInstanceUid, 
    string destinationAe,
    string serverHost, int serverPort,
    string serverAeTitle, string clientAeTitle)
{
    var client = DicomClientFactory.Create(serverHost, serverPort, false, 
        clientAeTitle, serverAeTitle);
    
    var request = new DicomCMoveRequest(destinationAe, studyInstanceUid, 
        seriesInstanceUid);
    
    request.OnResponseReceived = (req, response) =>
    {
        Console.WriteLine($"Status: {response.Status}, " +
            $"Completed: {response.Completed}/{response.Remaining + response.Completed}");
    };
    
    await client.AddRequestAsync(request);
    await client.SendAsync();
}

26.7 C-GET: Direct Retrieval

C-GET retrieves images directly (if the server supports it):

public async Task GetStudyAsync(
    string studyInstanceUid,
    string serverHost, int serverPort,
    string serverAeTitle, string clientAeTitle)
{
    var client = DicomClientFactory.Create(serverHost, serverPort, false, 
        clientAeTitle, serverAeTitle);
    
    var receivedFiles = new List<DicomDataset>();
    
    // Create C-GET request
    var request = new DicomCGetRequest(studyInstanceUid);
    
    // Handle incoming C-STORE sub-operations
    client.OnCStoreRequest += async (req) =>
    {
        try
        {
            receivedFiles.Add(req.Dataset);
            
            // Save the file
            var sopUid = req.Dataset.GetString(DicomTag.SOPInstanceUID);
            var filePath = Path.Combine("Downloads", $"{sopUid}.dcm");
            await req.File.SaveAsync(filePath);
            
            Console.WriteLine($"Received and saved: {filePath}");
            
            return new DicomCStoreResponse(req, DicomStatus.Success);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Failed to save: {ex.Message}");
            return new DicomCStoreResponse(req, DicomStatus.ProcessingFailure);
        }
    };
    
    // Handle C-GET responses
    request.OnResponseReceived = (req, response) =>
    {
        if (response.Status == DicomStatus.Pending)
        {
            Console.WriteLine($"C-GET Progress: {response.Completed}/" +
                $"{response.Remaining + response.Completed}");
        }
        else if (response.Status == DicomStatus.Success)
        {
            Console.WriteLine($"C-GET completed. Received {receivedFiles.Count} files");
        }
        else
        {
            Console.WriteLine($"C-GET failed: {response.Status}");
        }
    };
    
    await client.AddRequestAsync(request);
    await client.SendAsync();
}

26.8 Complete Working Example: Query and Retrieve Workflow

Here’s a practical example that combines operations:

public class DicomWorkflowService
{
    private readonly string _serverHost;
    private readonly int _serverPort;
    private readonly string _serverAeTitle;
    private readonly string _clientAeTitle;
    
    public DicomWorkflowService(string serverHost, int serverPort, 
        string serverAeTitle, string clientAeTitle)
    {
        _serverHost = serverHost;
        _serverPort = serverPort;
        _serverAeTitle = serverAeTitle;
        _clientAeTitle = clientAeTitle;
    }
    
    public async Task<bool> TestConnectionAsync()
    {
        var client = DicomClientFactory.Create(_serverHost, _serverPort, false, 
            _clientAeTitle, _serverAeTitle);
        
        var request = new DicomCEchoRequest();
        bool success = false;
        
        request.OnResponseReceived += (req, response) =>
        {
            success = response.Status == DicomStatus.Success;
        };
        
        await client.AddRequestAsync(request);
        await client.SendAsync();
        
        return success;
    }
    
    public async Task<List<StudyInfo>> QueryStudiesForPatientAsync(string patientId)
    {
        var studies = new List<StudyInfo>();
        var client = DicomClientFactory.Create(_serverHost, _serverPort, false, 
            _clientAeTitle, _serverAeTitle);
        
        var request = new DicomCFindRequest(DicomQueryRetrieveLevel.Study)
        {
            Dataset =
            {
                { DicomTag.PatientID, patientId },
                { DicomTag.PatientName, string.Empty },
                { DicomTag.StudyInstanceUID, string.Empty },
                { DicomTag.StudyDate, string.Empty },
                { DicomTag.StudyDescription, string.Empty },
                { DicomTag.ModalitiesInStudy, string.Empty },
                { DicomTag.NumberOfStudyRelatedSeries, string.Empty },
                { DicomTag.NumberOfStudyRelatedInstances, string.Empty }
            }
        };
        
        request.OnResponseReceived = (req, response) =>
        {
            if (response.Status == DicomStatus.Pending && response.HasDataset)
            {
                studies.Add(new StudyInfo
                {
                    StudyInstanceUID = response.Dataset.GetString(DicomTag.StudyInstanceUID),
                    PatientName = response.Dataset.GetString(DicomTag.PatientName),
                    StudyDate = response.Dataset.GetString(DicomTag.StudyDate),
                    StudyDescription = response.Dataset.GetString(DicomTag.StudyDescription),
                    Modality = response.Dataset.GetString(DicomTag.ModalitiesInStudy),
                    NumberOfImages = response.Dataset.GetString(DicomTag.NumberOfStudyRelatedInstances)
                });
            }
        };
        
        await client.AddRequestAsync(request);
        await client.SendAsync();
        
        return studies;
    }
    
    public async Task RetrieveStudyAsync(string studyInstanceUid, string destinationPath)
    {
        // First, set up a storage SCP to receive the images
        var storagePort = 11113;
        var storageAeTitle = _clientAeTitle;
        
        // Start storage server in background
        var storageTask = Task.Run(async () =>
        {
            await StartTemporaryStorageServerAsync(storagePort, destinationPath);
        });
        
        // Give the server time to start
        await Task.Delay(1000);
        
        // Now request the move
        await MoveStudyAsync(studyInstanceUid, storageAeTitle);
    }
    
    private async Task StartTemporaryStorageServerAsync(int port, string storagePath)
    {
        // Implementation of temporary storage server
        // (Similar to the StorageProvider shown earlier)
    }
}

public class StudyInfo
{
    public string StudyInstanceUID { get; set; }
    public string PatientName { get; set; }
    public string StudyDate { get; set; }
    public string StudyDescription { get; set; }
    public string Modality { get; set; }
    public string NumberOfImages { get; set; }
}

26.9 Error Handling and Connection Options

public class RobustDicomClient
{
    public async Task<T> ExecuteWithRetryAsync<T>(
        Func<Task<T>> operation, 
        int maxRetries = 3,
        int delayMilliseconds = 1000)
    {
        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                return await operation();
            }
            catch (DicomAssociationRejectedException ex)
            {
                Console.WriteLine($"Association rejected: {ex.RejectReason}");
                if (i == maxRetries - 1) throw;
            }
            catch (DicomNetworkException ex)
            {
                Console.WriteLine($"Network error: {ex.Message}");
                if (i == maxRetries - 1) throw;
            }
            
            await Task.Delay(delayMilliseconds * (i + 1));
        }
        
        throw new Exception("Max retries exceeded");
    }
    
    public IDicomClient CreateClientWithOptions(
        string host, int port, string callingAe, string calledAe)
    {
        var client = DicomClientFactory.Create(host, port, false, callingAe, calledAe);
        
        // Set client options
        client.AssociationTimeout = TimeSpan.FromSeconds(30);
        client.AssociationLingerTimeout = TimeSpan.FromSeconds(1);
        client.MaximumPDULength = 16384;
        client.ClientOptions.AssociationRequestTimeoutInMs = 5000;
        
        // Add logging
        client.Logger = new ConsoleLogger();
        
        return client;
    }
}

These examples should give you a solid foundation for implementing DICOM operations in C# using fo-dicom. The library handles most of the low-level DICOM protocol details, letting you focus on your application logic.

Remember that in a hospital environment like Ramathibodi, you’ll need to coordinate with your PACS administrators to get the proper AE Titles configured and network access established before these operations will work.