How to Use fo-dicom to Build DICOM C-Store SCU Tool for Batch Sending DICOM Files
Building a DICOM C-Store SCU (Service Class User) tool is essential for medical imaging applications that require sending DICOM files to storage systems. This tutorial will guide you through creating a robust batch DICOM file sender using fo-dicom, a powerful open-source DICOM library written in C#.
Fo-DICOM provides comprehensive features for working with DICOM data, including support for reading, writing, and manipulating DICOM files. This guide demonstrates how to create a DICOM C-Store SCU tool capable of batch sending DICOM files for testing and verification purposes in medical imaging environments.
Installing fo-dicom Dependencies
To begin, install the required fo-dicom packages using NuGet Package Manager in Visual Studio:
dotnet add package fo-dicom
dotnet add package fo-dicom.Codecs
dotnet add package fo-dicom.Imaging.ImageSharp
These packages provide the core DICOM functionality, codec support, and imaging capabilities needed for our C-Store SCU tool.
Setting Up Logging with NLog
First, create an NLog.config file to manage application logging:
<?xml version="1.0" encoding="utf-8"?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true"
internalLogLevel="Info"
internalLogFile=".\internal-nlog.txt">
<!-- Define log output targets -->
<targets>
<!-- File output with size-based rolling -->
<target name="logfile"
xsi:type="File"
fileName="logs/logfile.log"
layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}"
maxArchiveFiles="10"
archiveAboveSize="5242880"
archiveFileName="logs/archived/logfile.{#}.log"
archiveNumbering="Sequence"
keepFileOpen="false"
concurrentWrites="true"
enableFileDelete="true" />
<!-- Error file output -->
<target name="errorFile"
xsi:type="File"
fileName="logs/error.log"
layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}"
maxArchiveFiles="10"
archiveAboveSize="5242880"
archiveFileName="logs/archived/logfile.{#}.log"
archiveNumbering="Sequence"
keepFileOpen="false"
concurrentWrites="true"
enableFileDelete="true" />
<target name="console"
xsi:type="ColoredConsole"
layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}">
<!-- Configure different colors for different log levels -->
<highlight-row condition="level == LogLevel.Debug" foregroundColor="DarkGray" />
<highlight-row condition="level == LogLevel.Info" foregroundColor="Gray" />
<highlight-row condition="level == LogLevel.Warn" foregroundColor="Yellow" />
<highlight-row condition="level == LogLevel.Error" foregroundColor="Red" />
<highlight-row condition="level == LogLevel.Fatal" foregroundColor="Red" backgroundColor="White" />
</target>
</targets>
<rules>
<!-- Rules: all logs to logfile -->
<logger name="*" minlevel="Info" writeTo="logfile" />
<logger name="*" minlevel="Debug" writeTo="console" />
<logger name="*" minlevel="Error" writeTo="errorFile" />
</rules>
</nlog>
Configuring Application Settings
Create an appsettings.json file to manage application configuration:
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
}
}
Creating the DICOM Sender Class
Create a DcmSender.cs file containing the core sending logic:
using FellowOakDicom;
using FellowOakDicom.Network;
using FellowOakDicom.Network.Client;
using NLog;
namespace DcmBatchSender;
public class DcmSender
{
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
public CancellationToken CancellationToken { get; set; }
public required string Ip { get; set; }
public int Port { get; set; }
public required string Aet { get; set; }
public required string MyAet { get; set; }
public int BatchSize { get; set; }
public int Threads { get; set; }
public async Task Start(string dir)
{
if (!Directory.Exists(dir))
{
_logger.Error($"Directory does not exist: {dir}");
return;
}
// Get all DICOM files in the directory
var dicomFiles = GetDicomFiles(dir);
_logger.Info($"Found Files Count: {dicomFiles.Count} ");
// Create batches of files
var batches = CreateBatches(dicomFiles, BatchSize);
// Send files using multiple threads
var tasks = new List<Task>();
var ge = batches.GetEnumerator();
while (ge.MoveNext())
{
if (CancellationToken.IsCancellationRequested)
break;
var batch = ge.Current;
var task = Task.Run(async () => { await SendDicomFile(batch); }, CancellationToken);
tasks.Add(task);
// Control concurrent thread count
if (tasks.Count < Threads) continue;
await Task.WhenAny(tasks);
tasks.RemoveAll(t => t.IsCompleted);
}
// Wait for all tasks to complete
await Task.WhenAll(tasks);
}
private List<string> GetDicomFiles(string directory)
{
var files = new List<string>();
try
{
var dicomExtensions = new[] { "*.dcm", "*.dicom" };
foreach (var extension in dicomExtensions)
{
files.AddRange(Directory.GetFiles(directory, extension, SearchOption.AllDirectories));
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Error occurred when enumerating files in directory: {directory} ");
}
return files;
}
private List<List<string>> CreateBatches(List<string> files, int batchSize)
{
var batches = new List<List<string>>();
for (int i = 0; i < files.Count; i += batchSize)
{
batches.Add(files.GetRange(i, Math.Min(batchSize, files.Count - i)));
}
return batches;
}
private async Task SendDicomFile(List<string> filePath)
{
try
{
_logger.Debug($"Beginning to send files: {string.Join(", ", filePath)}");
var client = DicomClientFactory.Create(Ip, Port, false, MyAet, Aet);
client.NegotiateAsyncOps();
// When StoreSCP uses DICOM-rs implementation
client.ServiceOptions.MaxPDULength = 16384;
foreach (var file in filePath)
{
var dicomFile = await DicomFile.OpenAsync(file);
var request = new DicomCStoreRequest(dicomFile);
// You can add your own tags
request.Command.Add(DicomVR.LO, new DicomTag(0x1211, 0x0001), "xdicom.com-dicomgate-2026");
request.Command.Add(DicomVR.LO, new DicomTag(0x1211, 0x1217), "1234567890");
request.OnResponseReceived += (_, args) =>
{
_logger.Info($"Received response status: {args.Status}");
};
await client.AddRequestAsync(request);
}
await client.SendAsync(CancellationToken);
_logger.Info($"Successfully sent files: {string.Join(", ", filePath)}");
}
catch (Exception ex)
{
_logger.Error(ex, $"Error occurred when sending files: {string.Join(", ", filePath)} ");
}
}
}
Implementing the Main Program
Modify your Program.cs file to include command-line interface functionality:
using FellowOakDicom;
using NLog;
using System.CommandLine;
namespace DcmBatchSender;
struct SenderDefaultOptions
{
public readonly string DefaultHost = "192.168.1.14";
public readonly string DefaultAet = "STORE-SCP";
public readonly string DefaultMyAet = "STORE-SCU";
public readonly int DefaultPort = 11111;
public readonly int DefaultBatchSize = 100;
public readonly int DefaultThreads = 10;
public readonly int DefaultTimeout = 30;
public readonly string DefaultDirs = "./";
public SenderDefaultOptions()
{
}
}
class Program
{
// Get logger for current class
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private static readonly SenderDefaultOptions DefaultOptions = new SenderDefaultOptions();
static async Task Main(string[] args)
{
// Register encoding provider before DicomSetupBuilder
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
var rootCommand = new RootCommand(description: "DICOM Batch Sending Tool");
Option<string> hostOption = new("--host", "-h")
{
Description = "Store-SCP Server IP Address.",
DefaultValueFactory = _ => DefaultOptions.DefaultHost,
};
Option<int> portOption = new("--port", "-P")
{
Description = "Remote Server Port.",
DefaultValueFactory = _ => DefaultOptions.DefaultPort,
};
Option<string> aetOption = new("--aet")
{
Description = "Store-SCP Server AeTitle.",
DefaultValueFactory = _ => DefaultOptions.DefaultAet,
};
Option<string> myOption = new("--myaet")
{
Description = "Local AeTitle.",
DefaultValueFactory = _ => DefaultOptions.DefaultMyAet,
};
Option<int> batchSizeOption = new("--batch-size")
{
Description = "How many files to send per connection.",
DefaultValueFactory = _ => DefaultOptions.DefaultBatchSize,
};
Option<int> threadsOption = new("--threads", "-t")
{
Description = "Maximum number of threads to run.",
DefaultValueFactory = _ => DefaultOptions.DefaultThreads,
};
Option<string> dirsOption = new("--files")
{
Description = "Directory containing DICOM files to send.",
DefaultValueFactory = _ => DefaultOptions.DefaultDirs,
};
rootCommand.Add(hostOption);
rootCommand.Add(portOption);
rootCommand.Add(aetOption);
rootCommand.Add(myOption);
rootCommand.Add(batchSizeOption);
rootCommand.Add(threadsOption);
rootCommand.Add(dirsOption);
rootCommand.SetAction(parseResult =>
{
if (parseResult.Errors.Count > 0)
{
Console.WriteLine("Parse Arguments:");
foreach (var error in parseResult.Errors)
{
Console.WriteLine($" - {error.Message}");
}
Console.WriteLine("\nUsage:");
Console.WriteLine("DcmBatchSender [Option]");
Console.WriteLine("\nOptions:");
Console.WriteLine(
$" --host, -h <host> Store-SCP Server IP Address. Default: {DefaultOptions.DefaultHost}");
Console.WriteLine(
$" --port, -P <port> Store-SCP Server Port. Default: {DefaultOptions.DefaultPort}");
Console.WriteLine(
$" --aet <aet> Store-SCP Server AeTitle. Default: {DefaultOptions.DefaultAet}");
Console.WriteLine($" --myaet <myaet> Local AeTitle. Default: {DefaultOptions.DefaultMyAet}");
Console.WriteLine(
$" --batch-size <batch-size> Files to send per connection. Default: {DefaultOptions.DefaultBatchSize}");
Console.WriteLine($" --threads, -t <threads> Max Threads. Default: {DefaultOptions.DefaultThreads}");
Console.WriteLine(
$" --files <files> DICOM Files Directory. Default: {DefaultOptions.DefaultDirs}");
Environment.Exit(1);
}
var host = parseResult.GetRequiredValue(hostOption);
var port = parseResult.GetRequiredValue(portOption);
var aet = parseResult.GetRequiredValue(aetOption);
var myaet = parseResult.GetRequiredValue(myOption);
var batchSize = parseResult.GetRequiredValue(batchSizeOption);
var threads = parseResult.GetRequiredValue(threadsOption);
var dirs = parseResult.GetRequiredValue(dirsOption);
return RunApplication(CancellationToken.None, host, port, aet, myaet, batchSize, threads, dirs,
DefaultOptions.DefaultTimeout);
});
ParseResult parseResult = rootCommand.Parse(args);
await parseResult.InvokeAsync();
}
static async Task RunApplication(CancellationToken cancellationToken,
string ip, int port, string aet, string myaet,
int batchSize, int threads, string dir, int timeout)
{
// Create CancellationTokenSource to handle Ctrl+C signals
var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Console.CancelKeyPress += (_, e)
{
e.Cancel = true; // Prevent immediate program exit
Logger.Info("Received Ctrl+C signal, application will shut down gracefully...");
cancellationTokenSource.Cancel();
};
try
{
new DicomSetupBuilder()
.RegisterServices(s =>
s.AddFellowOakDicom()
.AddTranscoderManager<FellowOakDicom.Imaging.NativeCodec.NativeTranscoderManager>())
.SkipValidation()
.Build();
// Ensure log directories exist
var logDir = Path.Combine(AppContext.BaseDirectory, "logs");
var archivedDir = Path.Combine(logDir, "archived");
if (!Directory.Exists(logDir))
Directory.CreateDirectory(logDir);
if (!Directory.Exists(archivedDir))
Directory.CreateDirectory(archivedDir);
Logger.Info("🚀 Application Started, NLog configuration is OK!");
Logger.Info(
$"Configuration: IP={ip}, Port={port}, AET={aet}, MyAET={myaet}, BatchSize={batchSize}, Threads={threads}, Timeout={timeout}");
DcmSender dcmSender = new DcmSender()
{
CancellationToken = cancellationTokenSource.Token,
Ip = ip,
Port = port,
Aet = aet,
MyAet = myaet,
BatchSize = batchSize,
Threads = threads,
};
await dcmSender.Start(dir);
}
catch (OperationCanceledException)
{
// Normal cancellation, no need to log error
Logger.Info("Application is shutting down...");
}
catch (Exception ex)
{
Logger.Error(ex, "Application Crashed !");
throw;
}
finally
{
// Ensure all logs are written to disk and resources are closed
LogManager.Shutdown();
}
}
}
Project Configuration
Here’s the final .csproj file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Explicitly upgrade versions to resolve downgrade warnings -->
<PackageReference Include="StackExchange.Redis" Version="2.9.32" />
<PackageReference Include="System.CommandLine" Version="2.0.0-rc.1.25451.107" />
<PackageReference Include="System.Net.NameResolution" Version="4.3.0" />
<PackageReference Include="System.Net.Primitives" Version="4.3.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageReference Include="fo-dicom" Version="5.2.5" />
<PackageReference Include="fo-dicom.Codecs" Version="5.16.4" />
<PackageReference Include="fo-dicom.Imaging.ImageSharp" Version="5.2.4" />
<PackageReference Include="NLog" Version="6.0.5" />
<PackageReference Include="NLog.Extensions.Logging" Version="6.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="NLog.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
Key Features and Notes
- The project targets .NET 8.0, as indicated by net8.0 in the csproj file.
- The project uses NLog for comprehensive logging. Install NLog and NLog.Extensions.Logging packages to use it.
- client.ServiceOptions.MaxPDULength = 16384; sets the maximum length of the PDU (Protocol Data Unit) to 16384 bytes for optimal performance.
- The application supports concurrent file sending with configurable thread count and batch size.
- Command-line interface allows flexible configuration of DICOM server parameters.
Next Steps
After implementing this C-Store SCU client, you’ll need to create a C-Store SCP (Service Class Provider) to receive DICOM files. This completes the DICOM communication cycle for medical imaging systems.
For more information about building cloud-based DICOM platforms, see our guide on how to build cloud DICOM systems.
This guide provides a complete implementation of a DICOM C-Store SCU tool using fo-dicom, suitable for testing and validating medical imaging systems. The batch sending capability makes it ideal for large-scale DICOM file transfers in healthcare environments.