Examples

Seven runnable console apps. Each one is a small, self-contained project that uses the DicomKit NuGet package, so you can copy one straight into your own solution.

Download all samples (.zip)

The sample code is MIT licensed (see LICENSE in the download); use it freely in your own projects. DicomKit itself is licensed under the DicomKit License Agreement included in each package.

SampleWhat it shows
DumpFileParse a Part 10 file and print every element, sequences and parse issues; can also write a small test file.
EchoScuC-ECHO ("DICOM ping") to a remote SCP.
StoreScuSend a file or a whole folder to an SCP over one association.
StoreScpC-STORE / C-ECHO SCP that saves to a folder, hosted in the .NET Generic Host.
AnonymizeParallel folder de-identification with a persisted salt for consistent pseudonyms and UIDs.
ConvertToDicomWrap a PDF, JPEG, PNG or BMP in DICOM, attach it to an existing study, and extract it again.
LargeFileOpen a multi-gigabyte file metadata-only and stream its frames lazily with flat memory.

Before you start

  1. Get a free Developer key (all features, one machine, for development and testing) and put it in an environment variable once:
    setx DICOMKIT_LICENSE_KEY "YOUR-KEY"        # Windows (open a new terminal afterwards)
    export DICOMKIT_LICENSE_KEY="YOUR-KEY"      # Linux / macOS
    The library activates the key online on first use and then works offline. If the key is missing or invalid, the samples print one line saying so instead of a stack trace.
  2. Install the .NET 8 SDK or newer.
  3. Unzip the download and run the commands below from the DicomKit-Samples folder (it holds DicomKit.Samples.slnx). Run any sample without arguments to see its options.

A quick end-to-end network test on one machine (in separate terminals):

dotnet run --project DumpFile -- --create-sample test.dcm
dotnet run --project StoreScp -- ./received 11112 STORESCP
dotnet run --project EchoScu  -- 127.0.0.1 11112 STORESCP
dotnet run --project StoreScu -- 127.0.0.1 11112 test.dcm STORESCP

DumpFile

Prints the File Meta Information and the data set of a DICOM Part 10 file: tag, VR, keyword and value for every element, indented into sequence items, followed by any parse issues. Large values (pixel data, big blobs) are not loaded; they are shown with their length only. --create-sample writes a small synthetic CT (with a sequence and a private tag) to try this and the other samples on.

dotnet run --project DumpFile -- <file.dcm>
dotnet run --project DumpFile -- --create-sample <out.dcm>

The parser never throws on malformed input. Problems such as truncation or bad lengths are reported as DicomParseIssues (severity, code, byte offset, tag) and whatever could be read is still returned. The exit code is 3 when an issue of severity Error was found.

Key code

using DicomKit;
using DicomKit.IO;

// ...

using var file = await DicomFile.OpenAsync(path);

Console.WriteLine($"# {Path.GetFileName(path)}  ({file.TransferSyntax}, preamble: {file.HadPreamble}, complete: {file.IsComplete})");
Console.WriteLine();
Console.WriteLine("## File meta information");
Dump(file.FileMetaInfo, 0);
Console.WriteLine();
Console.WriteLine("## Data set");
Dump(file.Dataset, 0);

if (file.HasIssues)
{
    Console.WriteLine();
    Console.WriteLine($"## Parse issues ({file.Issues.Count})");
    foreach (var issue in file.Issues) Console.WriteLine($"  {issue}");
}

return file.Issues.Any(i => i.Severity == DicomParseSeverity.Error) ? 3 : 0;

// ...

static void Dump(DicomDataset dataset, int depth)
{
    var indent = new string(' ', depth * 2);
    foreach (var item in dataset)
    {
        var head = $"{indent}{item.Tag} {item.VR.Code()} {DicomDictionary.KeywordOf(item.Tag),-32}";
        switch (item)
        {
            case DicomSequence sq:
                Console.WriteLine($"{head} ({sq.Items.Count} item{(sq.Items.Count == 1 ? "" : "s")})");
                for (var i = 0; i < sq.Items.Count; i++)
                {
                    Console.WriteLine($"{indent}  > Item {i + 1}");
                    Dump(sq.Items[i], depth + 2);
                }
                break;

            case DicomElement e:
                Console.WriteLine($"{head} {Format(e, dataset)}");
                break;

            case DicomBulkElement bulk:
                // Not loaded: only offset and length are known until someone reads it.
                Console.WriteLine($"{head} <{bulk.Length:N0} bytes, deferred>");
                break;

            case DicomFragmentSequence frags:
                Console.WriteLine($"{head} <encapsulated, {frags.Fragments.Count} fragment(s), deferred>");
                break;

            default:
                Console.WriteLine(head);
                break;
        }

        if (item.IsRecovered) Console.WriteLine($"{indent}    (recovered: {item.Recovery})");
    }
}

API used: DicomFile.OpenAsync, DicomDataset enumeration, DicomElement / DicomSequence / DicomBulkElement / DicomFragmentSequence, DicomDictionary.KeywordOf, DicomFile.Issues, DicomFile.SaveAsync.

EchoScu

Sends one C-ECHO to an SCP and prints the status and round-trip time. Exit code 0 on status Success. An association rejection (wrong called AE title, calling AE not allowed) is reported with the result, source and reason codes of the A-ASSOCIATE-RJ.

dotnet run --project EchoScu -- <host> <port> [calledAE=ANY_SCP] [callingAE=DICOMKIT_SCU]
dotnet run --project EchoScu -- 127.0.0.1 11112 STORESCP

Key code

using System.Diagnostics;
using System.Globalization;
using DicomKit.Network;

// ...

var options = new DicomClientOptions
{
    CalledAE = args.Length > 2 ? args[2] : "ANY_SCP",
    CallingAE = args.Length > 3 ? args[3] : "DICOMKIT_SCU",
    ConnectTimeout = TimeSpan.FromSeconds(10),
    // Verification is always proposed, so no StorageSopClasses are needed for an echo.
};

var sw = Stopwatch.StartNew();
try
{
    await using var client = await DicomClient.ConnectAsync(args[0], port, options);
    var status = await client.EchoAsync();
    await client.ReleaseAsync();

    Console.WriteLine($"C-ECHO {args[0]}:{port} ({options.CallingAE} -> {options.CalledAE}): status 0x{status:X4} in {sw.ElapsedMilliseconds} ms");
    return status == DimseStatus.Success ? 0 : 1;
}
catch (DicomAssociationRejectedException ex)
{
    // Typical causes: wrong called AE title, or our calling AE is not on the SCP's allow list.
    Console.Error.WriteLine($"Association rejected: result {ex.Result}, source {ex.RejectSource}, reason {ex.Reason}");
    return 1;
}
catch (Exception ex) // connection refused, timeout, protocol error
{
    Console.Error.WriteLine($"Could not reach {args[0]}:{port}: {ex.Message}");
    return 1;
}

API used: DicomClient.ConnectAsync, DicomClientOptions, DicomClient.EchoAsync, DicomClient.ReleaseAsync, DicomAssociationRejectedException.

StoreScu

Sends a file, or every file under a folder (recursively), to a C-STORE SCP over a single association. Non-DICOM files are skipped. Exit code 0 when every file was stored with status Success.

dotnet run --project StoreScu -- <host> <port> <file-or-folder> [calledAE=ANY_SCP] [callingAE=DICOMKIT_SCU]
dotnet run --project StoreScu -- 127.0.0.1 11112 ./images STORESCP
  1. A metadata-only parse of every file (DicomStreamParser.ParseMetadataAsync, which stops at Pixel Data) collects the SOP classes and transfer syntaxes to propose. An association must announce them up front, at most DicomClient.MaxStorageSopClasses of them.
  2. Each file is opened with DicomFile.OpenAsync and sent with DicomClient.StoreAsync. Pixel data stays on disk and is streamed into PDUs, so file size does not matter. Compressed files are sent unchanged when the SCP accepts their transfer syntax.

Key code

using System.Diagnostics;
using System.Globalization;
using DicomKit;
using DicomKit.IO;
using DicomKit.Network;

// ...

// Pass 1: a metadata-only parse of each file (stops at Pixel Data, never reads it) to learn which SOP classes and
// transfer syntaxes to propose. An association must announce them up front.
var sopClasses = new HashSet<string>(StringComparer.Ordinal);
var syntaxes = new List<string> { DicomUids.ExplicitVRLittleEndian, DicomUids.ImplicitVRLittleEndian };
var sendable = new List<string>();
foreach (var path in paths)
{
    using var meta = await DicomStreamParser.ParseMetadataAsync(path);
    var sopClass = meta.Dataset.GetString(DicomTag.SOPClassUID);
    if (string.IsNullOrEmpty(sopClass) || meta.Dataset.GetString(DicomTag.SOPInstanceUID) is null)
    {
        Console.WriteLine($"skip  {path} (not a DICOM instance)");
        continue;
    }
    sopClasses.Add(sopClass);
    if (!syntaxes.Contains(meta.TransferSyntax.Uid)) syntaxes.Add(meta.TransferSyntax.Uid); // e.g. JPEG files stay compressed
    sendable.Add(path);
}

// ...

var options = new DicomClientOptions
{
    CalledAE = args.Length > 3 ? args[3] : "ANY_SCP",
    CallingAE = args.Length > 4 ? args[4] : "DICOMKIT_SCU",
    StorageSopClasses = [.. sopClasses],
    TransferSyntaxes = syntaxes,
};

// ...

// Pass 2: send. OpenAsync keeps pixel data on disk; StoreAsync streams it into PDUs, so file size does not matter.
var (ok, failed) = (0, 0);
var sw = Stopwatch.StartNew();
try
{
    await using var client = await DicomClient.ConnectAsync(host, port, options);
    foreach (var path in sendable)
    {
        try
        {
            using var file = await DicomFile.OpenAsync(path);
            var status = await client.StoreAsync(file);
            Console.WriteLine($"0x{status:X4}  {path}");
            if (status == DimseStatus.Success) ok++; else failed++;
        }
        catch (InvalidOperationException ex) // e.g. the SCP accepted no presentation context for this SOP class / syntax
        {
            Console.WriteLine($"fail  {path}: {ex.Message}");
            failed++;
        }
        if (client.IsFaulted) { Console.Error.WriteLine("Association lost."); break; }
    }
    if (!client.IsFaulted) await client.ReleaseAsync();
}
catch (DicomAssociationRejectedException ex)
{
    Console.Error.WriteLine($"Association rejected: result {ex.Result}, source {ex.RejectSource}, reason {ex.Reason}");
    return 1;
}
catch (Exception ex) // connection refused, timeout, protocol error
{
    Console.Error.WriteLine($"Error talking to {host}:{port}: {ex.Message}");
    return 1;
}

Console.WriteLine($"Sent {ok} of {sendable.Count} in {sw.Elapsed.TotalSeconds:F1} s ({failed} failed, {paths.Length - sendable.Count} skipped).");

StoreScp

A C-STORE and C-ECHO SCP that saves every received object as <storageFolder>/<CallingAE>/<SOPInstanceUID>.dcm, hosted in the .NET Generic Host. Stop it with Ctrl+C; open associations are drained first.

dotnet run --project StoreScp -- <storageFolder> [port=11112] [aeTitle=STORESCP]
  • AddDicomKit registers the toolkit services and the log router. Toolkit logs are forwarded to the host ILogger (the console here) and also written as JSON lines per channel to <storageFolder>/../dicomkit-logs.
  • AddDicomKitServer<THandler> runs the SCP as a hosted service. The sample handler wraps the built-in FileSystemStoreHandler and logs each object; replace it with your own IDicomStoreHandler for validation, routing or database work.
  • DicomServerOptions: port, AE title, called-AE check, calling-AE allow list, spool directory for large data sets.
  • DicomServer.Statistics, printed on shutdown.

For a server without custom logic, services.AddDicomKitServer(storageFolder, o => o.Port = 11112) is enough.

Key code

using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using DicomKit;
using DicomKit.Logging;
using DicomKit.Network;

// ...

var builder = Host.CreateApplicationBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Information);

// Toolkit-wide services: log router (file sink + forwarding into ILogger), parser defaults, anonymizer, batch processor.
builder.Services.AddDicomKit(o =>
{
    o.Logging.Directory = Path.Combine(Path.GetDirectoryName(storage) ?? storage, "dicomkit-logs");
    o.Logging.EnabledChannels = DicomKitLogChannel.NetworkLogs | DicomKitLogChannel.SafeLogs | DicomKitLogChannel.ErrorLogs;
});

// The SCP as a hosted service. The built-in FileSystemStoreHandler does the writing; LoggingStoreHandler wraps it
// to show how to plug in your own logic (validation, routing, a database insert, ...).
builder.Services.AddSingleton(new FileSystemStoreHandler(storage));
builder.Services.AddDicomKitServer<LoggingStoreHandler>(o =>
{
    o.Port = port;
    o.AeTitle = aeTitle;
    o.RequireCalledAeMatch = false;          // set true to reject associations addressed to another AE title
    // o.AllowedCallingAeTitles = new HashSet<string> { "MODALITY1" };   // optional allow list
    o.SpoolDirectory = Path.Combine(Path.GetTempPath(), "dicomkit-scp-spool"); // big data sets spool here, not to memory
});

using var host = builder.Build();
var log = host.Services.GetRequiredService<ILogger<Program>>();
log.LogInformation("StoreScp {AeTitle} listening on port {Port}, storing to {Storage}", aeTitle, port, storage);

await host.RunAsync();

// ...

/// <summary>Logs each received object, then delegates the write to <see cref="FileSystemStoreHandler"/>.</summary>
internal sealed class LoggingStoreHandler(FileSystemStoreHandler inner, ILogger<LoggingStoreHandler> log) : IDicomStoreHandler
{
    public async ValueTask<DicomStoreResult> OnStoreAsync(DicomStoreRequest request, CancellationToken ct)
    {
        var result = await inner.OnStoreAsync(request, ct);
        log.LogInformation("C-STORE from {CallingAE}: {SopClass} {SopInstance} ({Bytes:N0} bytes, {Syntax}) -> status 0x{Status:X4}",
            request.Association.CallingAE, request.SopClassUid, request.SopInstanceUid, request.DatasetLength,
            request.TransferSyntax, result.Status);
        return result;
    }
}

Anonymize

De-identifies every DICOM file under a folder in parallel with DicomBatchProcessor.AnonymizeAsync, writing the results to an output folder, each named by its new SOP Instance UID. Non-DICOM files are reported as skipped and left alone.

dotnet run --project Anonymize -- <inputFolder> <outputFolder> [saltFile=anonymize.salt]

UID remapping and ID pseudonyms are deterministic for a given salt. Without one, DicomAnonymizer generates a random salt per instance, so two runs would give the same study two different sets of new UIDs. The sample reads the salt from saltFile when it exists and sets AnonymizationProfile.Salt; otherwise it lets the anonymizer generate one (IsSaltGenerated) and saves DicomAnonymizer.Salt to the file. Run it twice on the same input: the output file names are identical, so a study de-identified in two batches stays one study.

Treat the salt as a secret (whoever holds it can check whether a known UID or ID maps to a given pseudonym) and keep it out of the folder you share. The profile defaults to a pragmatic subset of the PS3.15 Basic Application Confidentiality Profile; edit AnonymizationProfile.Rules, RemovePrivateTags and DateShiftDays for your site policy.

Key code

using DicomKit.Anonymization;
using DicomKit.Batch;

// ...

// The default profile is a pragmatic subset of the PS3.15 Basic Profile; tweak the rules for your site policy.
var profile = new AnonymizationProfile
{
    RemovePrivateTags = true,
    // DateShiftDays = -30,                                     // shift dates instead of keeping them
};
// profile.Rules[DicomTag.StudyDescription] = AnonymizeAction.Remove;

// Salt persistence: load it if we have one, otherwise let the anonymizer generate one and save it afterwards.
var hadSalt = File.Exists(saltFile);
if (hadSalt) profile.Salt = (await File.ReadAllTextAsync(saltFile)).Trim();

var anonymizer = new DicomAnonymizer(profile);
if (anonymizer.IsSaltGenerated)
{
    await File.WriteAllTextAsync(saltFile, anonymizer.Salt);
    Console.WriteLine($"Generated a new salt and saved it to {Path.GetFullPath(saltFile)}");
}
else
{
    Console.WriteLine($"Using the salt from {Path.GetFullPath(saltFile)}");
}

// Same salt => same mapping. Run the sample twice and this line prints the same UID both times.
Console.WriteLine($"Example remap: 1.2.3.4.5 -> {anonymizer.RemapUid("1.2.3.4.5")}");

// Non-DICOM files (a readme.txt, a JPEG) are not touched: the batch processor reports them as skipped.
var files = Directory.GetFiles(input, "*", SearchOption.AllDirectories);
var options = new DicomBatchOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
var report = await new DicomBatchProcessor().AnonymizeAsync(files, output, anonymizer, options);

ConvertToDicom

Wraps a PDF, JPEG, PNG or BMP in a DICOM Part 10 file with DicomConverter. The format is detected from the file content, not the extension.

InputResult
PDFEncapsulated PDF Storage, Explicit VR Little Endian
JPEG (baseline, extended, lossless)Secondary Capture Image Storage, the JPEG kept byte for byte as one encapsulated fragment
PNG, BMPSecondary Capture Image Storage, uncompressed RGB or MONOCHROME2
several imagesone multi-frame Secondary Capture instance (all JPEG, or all PNG/BMP, same size)
dotnet run --project ConvertToDicom -- scan.pdf scan.dcm --patient-name "DOE^JANE" --patient-id 12345
dotnet run --project ConvertToDicom -- page1.jpg page2.jpg pages.dcm --study-uid 1.2.840.99.1
dotnet run --project ConvertToDicom -- requisition.jpg req.dcm --from-dicom study-image.dcm
dotnet run --project ConvertToDicom -- --extract scan.dcm scan-out.pdf

--from-dicom copies the patient and study attributes (and the Study Instance UID) from an existing file of the study, so the converted document joins that study as a new series. Options given on the command line win over copied values. Progressive JPEGs are rejected: DICOM has no transfer syntax for them and the converter never re-compresses; save the image as baseline JPEG or PNG first. Exit codes: 1 file not found, 2 bad arguments, 3 input cannot be converted. Check the result with DumpFile.

Key code

using System.Globalization;
using DicomKit;
using DicomKit.Conversion;
using DicomKit.IO;

// ...

var output = inputs[^1];
inputs.RemoveAt(inputs.Count - 1);

// ...

if (fromDicom is not null)
{
    // Typically an image of the study the document belongs to: the new instance joins that study as a new series.
    source = await DicomFile.OpenAsync(fromDicom);
    options.CopyFrom = source.Dataset;
}

using var file = await DicomConverter.FromFilesAsync(inputs, options);
await file.SaveAsync(output);

// ...

var bytes = file.Dataset.Contains(DicomTag.PixelData)
    ? await DicomConverter.ExtractJpegAsync(file)
    : await DicomConverter.ExtractPdfAsync(file);

API used: DicomConverter.FromFilesAsync, DicomConversionOptions (including CopyFrom), DicomConverter.ExtractPdfAsync / ExtractJpegAsync, DicomFile.SaveAsync. The attributes written and the limitations are in the conversion guide.

LargeFile

Opens a DICOM file of any size metadata-only and streams its frames lazily. Only the header is parsed; pixel data stays on disk and each frame is read on demand, so memory use stays flat for multi-gigabyte files.

dotnet run -c Release --project LargeFile -- <file.dcm> [maxFrames]
dotnet run -c Release --project LargeFile -- --generate <out.dcm> <sizeMB>

--generate writes a synthetic 512x512 16-bit multi-frame CT of about sizeMB megabytes. The pixel bytes come from a custom DicomBulkSource, so even a multi-GB file is written without being held in memory.

  • DicomStreamParser.ParseMetadataAsync: stops after Pixel Data without reading it.
  • LazyDicomPixelData.TryCreate and PrepareAsync: locate frames (native, or encapsulated via offset tables or a fragment scan) without reading pixels.
  • Random access with GetFrameLengthAsync and ReadFrameAsync into your own buffer.
  • Sequential streaming with GetFramesAsync: one reused pooled buffer, so copy a frame if you keep it.

Keep the DicomFile open while frames are read, and dispose it when done. More on the memory model in Large files.

Key code

using System.Diagnostics;
using System.Globalization;
using DicomKit;
using DicomKit.IO;

// ...

using var file = await DicomStreamParser.ParseMetadataAsync(path);
Console.WriteLine($"Opened {Path.GetFileName(path)} ({new FileInfo(path).Length:N0} bytes) metadata-only in {sw.ElapsedMilliseconds} ms");
Console.WriteLine($"  Patient: {file.Dataset.GetString(DicomTag.PatientName)}  Modality: {file.Dataset.GetString(DicomTag.Modality)}  Syntax: {file.TransferSyntax}");

// ...

if (!LazyDicomPixelData.TryCreate(file.Dataset, out var pixels) || pixels is null)
{
    Console.Error.WriteLine("The file has no usable pixel data.");
    return 1;
}

// Locates frames (offset tables / fragment scan for compressed data) without reading pixel bytes.
await pixels.PrepareAsync();
Console.WriteLine($"  {pixels.FrameCount} frame(s) of {pixels.Columns}x{pixels.Rows}, {pixels.BitsAllocated} bits, " +
                  $"{(pixels.IsEncapsulated ? "encapsulated" : $"{pixels.FrameLength:N0} bytes each")}, pixel data {pixels.Length:N0} bytes");

// Random access: read just the last frame into a buffer we own.
var last = pixels.FrameCount - 1;
var buffer = new byte[await pixels.GetFrameLengthAsync(last)];
var read = await pixels.ReadFrameAsync(last, buffer);

// ...

// Sequential streaming: each frame arrives in one reused pooled buffer, valid only until the loop moves on.
sw.Restart();
long bytes = 0;
var frames = 0;
var peakWorkingSet = Environment.WorkingSet;
await foreach (var frame in pixels.GetFramesAsync())
{
    bytes += frame.Data.Length;
    if (++frames % 64 == 0) peakWorkingSet = Math.Max(peakWorkingSet, Environment.WorkingSet);
    if (frames >= maxFrames) break;
}

DicomKit is a developer library, not a medical device.