Documentation
Install the package, add your license key, and you are done. Every sample on this page uses the library's real public API.
Get started
1. Install
The packages are versioned together and target .NET 8, .NET 9 and .NET 10.
dotnet add package DicomKit # core, networking, conversion, anonymization
dotnet add package DicomKit.Imaging # decode, render, PNG/JPEG export
dotnet add package DicomKit.Web # DICOMweb models and client
dotnet add package DicomKit.Web.AspNetCore # DICOMweb server endpoints
2. Open a file
using DicomKit;
using DicomKit.IO;
using var file = await DicomFile.OpenAsync("image.dcm"); // metadata parsed, pixel data left on disk
Console.WriteLine(file.Dataset.GetString(DicomTag.PatientName));
foreach (var issue in file.Issues)
Console.WriteLine(issue); // problems are reported, never thrown
3. Query a PACS (C-FIND)
using DicomKit;
using DicomKit.Network;
using DicomKit.Query;
await using var client = await DicomClient.ConnectAsync("pacs.example.org", 104, new DicomClientOptions
{
CallingAE = "WORKSTATION", CalledAE = "PACS",
QueryRetrieveSopClasses = [DicomUids.StudyRootQueryRetrieveFind],
});
var query = DicomQuery.Studies(patientName: "DOE*", studyDate: "20260101-20260131");
await foreach (var study in client.FindAsync(DicomUids.StudyRootQueryRetrieveFind, query))
Console.WriteLine(study.GetString(DicomTag.StudyInstanceUID));
await client.ReleaseAsync();
C-MOVE, C-GET, worklist, sending with C-STORE and running your own server are in Networking.
What the toolkit can do
| Area | What you get | Main types |
|---|---|---|
| Networking (client) | C-ECHO, C-STORE, C-FIND (Patient and Study Root), Modality Worklist, C-MOVE and C-GET with progress and C-CANCEL | DicomClient, DicomQuery |
| Networking (server) | A TCP listener that answers C-ECHO, receives C-STORE, serves C-FIND / worklist and C-MOVE / C-GET from your handlers | DicomServer, IDicomStoreHandler, IDicomQueryHandler, IDicomRetrieveHandler |
| Large files | Multi-gigabyte files parsed with memory bounded by the metadata; frames read one at a time | DicomStreamParser, LazyDicomPixelData |
| Imaging | Decode RLE, JPEG, JPEG-LS, JPEG 2000; windowing and LUTs; PNG / JPEG export; thumbnails | DicomImage, RgbaFrame |
| DICOMweb | DICOM JSON and XML, QIDO-RS / WADO-RS / STOW-RS client, ASP.NET Core server endpoints | DicomWebClient, MapDicomWeb |
| Conversion | PDF to Encapsulated PDF; JPEG, PNG, BMP to Secondary Capture; extract the PDF or JPEG again | DicomConverter |
| Anonymization | Profile-based de-identification with salted, repeatable UID remapping; parallel over folders | DicomAnonymizer, DicomBatchProcessor |
Receive and send many large files at once
The server gives every association its own reader and worker, so a slow sender only slows itself. Objects above a threshold are spooled to disk while they arrive, and all associations together share one memory budget; past it, everything spools. Each association buffers only a few unhandled requests before it stops reading the socket (back-pressure).
using DicomKit.Network;
var options = new DicomServerOptions
{
Port = 104,
AeTitle = "ARCHIVE",
MaxAssociations = 64, // simultaneous senders; more get A-ASSOCIATE-RJ (default 512)
InMemoryDatasetThreshold = 8 * 1024 * 1024, // larger objects spool to disk while they arrive
MaxInMemoryDatasetBytes = 512L * 1024 * 1024, // memory shared by all associations; then everything spools
MaxDatasetLength = 4L * 1024 * 1024 * 1024, // largest single object (default 4 GiB)
MaxPendingRequestsPerAssociation = 8, // back-pressure per association
SpoolDirectory = @"D:\spool",
};
await using var server = new DicomServer(options, new FileSystemStoreHandler(@"D:\incoming"));
await server.StartAsync(); // {root}/{CallingAE}/{SOPInstanceUID}.dcm
Console.WriteLine($"{server.Statistics.ActiveAssociations} active, {server.Statistics.StoresCompleted} stored");
A DicomClient runs one operation at a time on its association. To send in parallel, open several clients. DicomFile.OpenAsync leaves pixel data on disk and StoreAsync streams it into PDUs, so file size does not matter:
var clientOptions = new DicomClientOptions
{
CallingAE = "MODALITY", CalledAE = "ARCHIVE",
StorageSopClasses = [DicomUids.CTImageStorage, DicomUids.MRImageStorage],
};
await Parallel.ForEachAsync(paths.Chunk(500), new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (batch, ct) =>
{
await using var client = await DicomClient.ConnectAsync("pacs.local", 104, clientOptions, ct: ct);
foreach (var path in batch)
{
using var file = await DicomFile.OpenAsync(path, ct: ct);
var status = await client.StoreAsync(file, ct); // DimseStatus.Success == 0x0000
}
await client.ReleaseAsync(ct);
});
A custom handler decides what happens to each received object, for example a database row plus a file:
var handler = new DelegateStoreHandler(async (request, ct) =>
{
using var file = await request.OpenAsync(ct); // parsed on demand
var accession = file.Dataset.GetString(DicomTag.AccessionNumber);
await using var output = File.Create(Path.Combine(root, request.SopInstanceUid + ".dcm"));
await request.WritePart10Async(output, ct); // the bytes exactly as received
return DicomStoreResult.Success; // or DicomStoreResult.OutOfResources("disk full")
});
Limits for one file (element count, nesting, spool size) are in Large files; network timeouts are in Networking.
Anonymize a folder
using DicomKit.Anonymization;
using DicomKit.Batch;
var anonymizer = new DicomAnonymizer(new AnonymizationProfile { RemovePrivateTags = true, Salt = savedSalt });
var report = await new DicomBatchProcessor().AnonymizeAsync(Directory.GetFiles(input), output, anonymizer,
new DicomBatchOptions { MaxDegreeOfParallelism = Environment.ProcessorCount });
Console.WriteLine($"{report.Succeeded} done, {report.Failed} failed, {report.Skipped} skipped, {report.FilesPerSecond:F0} files/s");
The same salt gives the same pseudonyms and UIDs, so a study sent in two batches stays one study. Non-DICOM files are skipped, and a bad file never stops the batch.
Why DicomKit
- Modern .NET. Built for .NET 8, 9 and 10, asynchronous from end to end, with networking on
System.IO.Pipelines. - Pure managed code. Runs the same on Windows, Linux and macOS. No native binaries to deploy, including the JPEG, JPEG-LS and JPEG 2000 decoders.
- Large files without large memory. Pixel data stays on disk until you ask for a frame. A 1 GiB file parses with about 60 KB allocated and nothing on the large object heap (measured in the library's tests).
- Bad data is reported, not thrown. Truncated files, wrong lengths and mislabelled transfer syntaxes become
DicomParseIssuerecords; the parser is fuzz-tested. - Client and server for every service. C-ECHO, C-STORE, C-FIND, Modality Worklist, C-MOVE and C-GET, with C-CANCEL, progress, back-pressure, and a limit or timeout on every resource.
- Safe by default. A C-MOVE can only reach destinations you configured; DICOMweb input is capped by size and part count, and the XML reader refuses DTDs.
- One family. Networking, imaging, DICOMweb client and server, PDF and image conversion, and anonymization share one data model and one version.
- Easy to use. XML documentation for IntelliSense in every package.
- Fair licensing. Perpetual and term licenses never phone home after activation, air-gapped machines activate offline, and you move seats between machines yourself from the portal.
Support
Networking: C-ECHO, C-STORE, C-FIND, Worklist, C-MOVE, C-GET
DicomKit implements the DICOM upper layer (PS3.8) and these DIMSE services (PS3.4, PS3.7), as SCU and SCP:
| Service | SOP classes | SCU (DicomClient) | SCP (DicomServer) |
|---|---|---|---|
| C-ECHO | Verification | EchoAsync | always on |
| C-STORE | any storage class | StoreAsync | IDicomStoreHandler |
| C-FIND | Patient Root, Study Root Q/R FIND | FindAsync | IDicomQueryHandler |
| Modality Worklist | Modality Worklist FIND | FindWorklistAsync | IDicomQueryHandler |
| C-MOVE | Patient Root, Study Root Q/R MOVE | MoveAsync | IDicomRetrieveHandler + destination table |
| C-GET | Patient Root, Study Root Q/R GET | GetAsync | IDicomRetrieveHandler |
Presentation contexts for a service whose handler is missing are rejected at association time, so a peer learns up front what the server offers.
C-ECHO and C-STORE
using DicomKit.Network;
// SCP: every association runs in its own reader/worker pair; a slow modality only slows itself.
await using var server = new DicomServer(new DicomServerOptions { Port = 104, AeTitle = "ARCHIVE" },
new FileSystemStoreHandler(@"D:\incoming"));
await server.StartAsync();
// SCU
await using var client = await DicomClient.ConnectAsync("pacs.local", 104, new DicomClientOptions
{
CallingAE = "MODALITY",
CalledAE = "ARCHIVE",
StorageSopClasses = [DicomUids.CTImageStorage],
});
await client.EchoAsync();
using var file = await DicomFile.OpenAsync("ct.dcm");
var status = await client.StoreAsync(file);
A store handler gets a DicomStoreRequest whose bytes (memory or a spool file) are released when it returns. Call TakeOwnership() to keep them, then dispose the request or the file you opened yourself.
C-FIND (Query/Retrieve)
SCU
Propose the query SOP classes, then enumerate. Matches stream in as the SCP sends them.
using DicomKit.Query;
await using var client = await DicomClient.ConnectAsync("pacs.local", 104, new DicomClientOptions
{
CallingAE = "WORKSTATION",
CalledAE = "ARCHIVE",
QueryRetrieveSopClasses = [DicomUids.StudyRootQueryRetrieveFind, DicomUids.StudyRootQueryRetrieveMove],
});
// Ready-made identifiers: null arguments become empty return keys.
var query = DicomQuery.Studies(patientName: "DOE*", studyDate: "20260101-20260131", modalitiesInStudy: "CT");
await foreach (var study in client.FindAsync(DicomUids.StudyRootQueryRetrieveFind, query, ct))
Console.WriteLine($"{study.GetString(DicomTag.StudyInstanceUID)} {study.GetString(DicomTag.StudyDescription)}");
// Lower levels
var series = DicomQuery.Series(studyInstanceUid);
var images = DicomQuery.Images(studyInstanceUid, seriesInstanceUid);
var patients = DicomQuery.Patients(patientId: "PID*"); // PATIENT level: Patient Root only
You can also build any identifier by hand: set Query/Retrieve Level (0008,0052), put values on matching keys and add empty elements for the return keys you want.
- Cancelling
ct, or leaving theawait foreachearly (break), sends C-CANCEL-RQ and reads up to the SCP's final response, so the client stays usable. A cancelled enumeration throwsOperationCanceledException. - A failure status from the SCP (for example 0xA900 for a level the model does not have, 0xA700 out of resources) throws
DicomServiceExceptionwithStatusandComment. The client stays usable. - A
DicomClientruns one operation at a time. Starting another while an enumeration is open throwsInvalidOperationException.
SCP
Implement IDicomQueryHandler and yield one response identifier per match. DicomQueryMatcher covers the common matching rules, and CreateResponse returns exactly the requested keys.
sealed class MyQueries(IReadOnlyList<DicomDataset> instances) : IDicomQueryHandler
{
public async IAsyncEnumerable<DicomDataset> OnFindAsync(DicomFindRequest request, [EnumeratorCancellation] CancellationToken ct)
{
var key = request.Level switch
{
DicomQueryRetrieveLevel.Patient => DicomTag.PatientID,
DicomQueryRetrieveLevel.Study => DicomTag.StudyInstanceUID,
DicomQueryRetrieveLevel.Series => DicomTag.SeriesInstanceUID,
_ => DicomTag.SOPInstanceUID,
};
var seen = new HashSet<string>();
foreach (var ds in instances) // or a database query built from request.Identifier
{
ct.ThrowIfCancellationRequested(); // C-CANCEL-RQ cancels ct
if (DicomQueryMatcher.Matches(request.Identifier, ds) && seen.Add(ds.GetString(key) ?? ""))
yield return DicomQueryMatcher.CreateResponse(request.Identifier, ds);
}
}
}
await using var server = new DicomServer(options, storeHandler: null, queryHandler: new MyQueries(all), retrieveHandler: null);
- Each yielded data set goes out at once as a Pending (0xFF00) response, so nothing is buffered. Set
request.OptionalKeysNotSupported = trueto send 0xFF01 instead. - The server adds Query/Retrieve Level to a response that lacks it. It does this on a copy and never changes your data set.
- To end with a specific status, throw
DicomServiceException(status, comment). Any other exception ends the query with 0xC000. A C-CANCEL-RQ ends it with 0xFE00. - Before your handler is called, the server rejects a missing identifier or level, and a level that is invalid for the model (PATIENT on Study Root), with 0xA900.
Matching (DicomQueryMatcher, PS3.4 C.2.2.2):
| Query value | Matches |
|---|---|
empty, or * | everything (universal) |
DOE^JOHN | single value. Case-sensitive, except person names, which are case-insensitive and ignore trailing ^. |
DOE*, D?E | wildcard. Linear time, so hostile patterns are harmless. |
20260101-20260131, -20260131, 20260101- | DA, TM and DT ranges. A shorter upper bound covers its whole span (-1430 includes 14:30:59). |
1.2.3\1.2.4 | UID list. For other string VRs a backslash also means "any of". |
| sequence with one item | any item of the candidate's sequence matches that item. An empty item is a return key only. |
Modality Worklist
var query = DicomQuery.Worklist(modality: "CT", scheduledStationAeTitle: "CT01", scheduledDate: "20260115-20260116");
await foreach (var item in client.FindWorklistAsync(query, ct))
{
var sps = item.GetSequence(DicomTag.ScheduledProcedureStepSequence)!.Items[0];
Console.WriteLine($"{item.GetString(DicomTag.AccessionNumber)} {sps.GetString(DicomTag.ScheduledProcedureStepStartTime)}");
}
On the SCP, the same IDicomQueryHandler receives worklist queries with request.Model == DicomQueryRetrieveModel.ModalityWorklist and request.Level == null. DicomQueryMatcher.Matches handles the Scheduled Procedure Step Sequence keys (modality, station AE, start date range). CreateResponse returns only the matching SPS items, each projected to the requested keys.
C-MOVE
SCU
var result = await client.MoveAsync(DicomUids.StudyRootQueryRetrieveMove, "VIEWER01",
DicomQuery.Retrieve(DicomQueryRetrieveLevel.Study, studyInstanceUid),
new Progress<DicomRetrieveStatus>(p => Console.WriteLine($"{p.Completed}/{p.Completed + p.Remaining}")), ct);
if (result.Status == DimseStatus.Warning) // 0xB000: some sub-operations failed
Console.WriteLine(string.Join(", ", result.FailedSopInstanceUids));
The final response is returned whatever its status: 0x0000, 0xB000, 0xA702 (nothing could be sent), 0xA801 (unknown destination), 0xA900 and so on. Cancelling sends C-CANCEL-RQ, waits for the SCP to confirm with 0xFE00, then throws OperationCanceledException. The client stays usable.
SCP
The retrieve handler only resolves the matching instances. The server opens the C-STORE association to the destination, sends the instances one at a time, reports progress after each and handles C-CANCEL.
sealed class MyRetrieve(MyDatabase db) : IDicomRetrieveHandler
{
public async ValueTask<IReadOnlyList<DicomRetrieveItem>> OnRetrieveAsync(DicomRetrieveRequest request, CancellationToken ct)
{
var rows = await db.FindInstancesAsync(request.Identifier, ct); // unique keys down to request.Level
return rows.Select(r => DicomRetrieveItem.FromFile(r.Path, r.SopClassUid, r.SopInstanceUid, r.TransferSyntaxUid)).ToList();
}
}
var options = new DicomServerOptions { Port = 104, AeTitle = "ARCHIVE" };
options.MoveDestinations["VIEWER01"] = new DicomApplicationEntity("VIEWER01", "10.0.0.21", 11112);
// optional, consulted first: options.MoveDestinationResolver = (ae, association) => LookUp(ae);
await using var server = new DicomServer(options, storeHandler, queryHandler, new MyRetrieve(db));
- A destination comes only from
MoveDestinationsorMoveDestinationResolver, never from the request. A C-MOVE cannot make the server connect anywhere it was not configured to. An unknown AE gets 0xA801. - Items are opened only when their turn comes, and the server disposes each file after sending it, so a 5,000-instance move holds one instance at a time.
DicomRetrieveItem.FromDatasetwraps an in-memory data set. - When an item carries
TransferSyntaxUid, the move association also proposes that syntax, so compressed objects go out as they are. Nothing is transcoded: an object with no matching accepted context fails its sub-operation. - Each C-STORE carries Move Originator AE Title and Message ID.
- Final status: 0x0000 when everything was sent, 0xB000 when some sub-operations failed or warned (with a Failed SOP Instance UID List), 0xA702 when none succeeded or the destination could not be reached, 0xFE00 after C-CANCEL (with the remaining count).
C-GET
C-GET sends the instances back on the requesting association, so the SCU acts as the C-STORE SCP. It has to negotiate that role: list the storage classes it can receive in RetrieveStorageSopClasses. They are proposed with SCP/SCU Role Selection (PS3.7 D.3.3.4).
await using var client = await DicomClient.ConnectAsync("pacs.local", 104, new DicomClientOptions
{
CallingAE = "WORKSTATION",
CalledAE = "ARCHIVE",
QueryRetrieveSopClasses = [DicomUids.StudyRootQueryRetrieveGet],
RetrieveStorageSopClasses = [DicomUids.CTImageStorage, DicomUids.MRImageStorage],
TransferSyntaxes = [DicomUids.ExplicitVRLittleEndian, DicomUids.ImplicitVRLittleEndian],
});
var result = await client.GetAsync(DicomUids.StudyRootQueryRetrieveGet,
DicomQuery.Retrieve(DicomQueryRetrieveLevel.Series, studyUid, seriesUid),
new FileSystemStoreHandler(@"D:\retrieved"), // or a DelegateStoreHandler
progress: null, ct);
- Received objects reach the
IDicomStoreHandlerexactly as on a C-STORE SCP: pooled memory, spooled to disk pastInMemoryDatasetThresholdor the client'sMaxInMemoryDatasetBytesbudget, released when the handler returns unless it callsTakeOwnership(). - On the SCP side nothing extra is needed: the same
IDicomRetrieveHandlerserves C-GET. The server sends each object only on a context whose SCP role it accepted for the requestor. Objects of classes the SCU did not negotiate fail their sub-operations (0xA702 when all fail). - A storage context accepted only for the SCP role (for example on a server with a retrieve handler but no store handler, or a class outside
AcceptedSopClasses) is for sending only: a C-STORE that arrives on it is refused with 0x0122.
Limits and deadlines
Every limit also applies to sub-operations.
| Option | Default | Effect |
|---|---|---|
DicomServerOptions.MaxIdentifierLength | 1 MiB | a longer C-FIND/C-MOVE/C-GET identifier aborts the association |
DicomServerOptions.MaxQueryResponses | 100,000 | further matches end the query with 0xA700 |
DicomServerOptions.MaxRetrieveSubOperations | 65,535 | more instances: 0xA702 (65,535 is the most a response count can hold) |
DicomServerOptions.SubOperationTimeout | 60 s | C-GET wait for the SCU's C-STORE-RSP, and the C-MOVE destination's response timeout. On expiry the C-GET stops (0xA702) and a late response is ignored. |
DicomServerOptions.MoveConnectTimeout | 15 s | connecting to a move destination |
DicomServerOptions.WriteTimeout | 60 s | every response and sub-operation send. For an object it restarts after each PDU the peer takes, so a slow reader is fine but a stalled one is dropped. |
DicomClientOptions.ResponseTimeout | 60 s | each response, pending response or C-GET C-STORE-RQ; also bounds the wind-down after C-CANCEL |
DicomClientOptions.MaxIdentifierLength / MaxDatasetLength | 1 MiB / 4 GiB | response identifiers / objects received in a C-GET |
More guarantees:
- A C-CANCEL never cuts a message short. Sub-operation messages are sent under the association's lifetime and the cancel takes effect between sub-operations. If an object fails part-way through a C-GET send (for example its pixel data cannot be read), the association is aborted, because it can no longer be trusted.
- A C-CANCEL-RQ that arrives while its request is still queued is remembered and cancels the request as it starts.
- One operation runs at a time per association: the asynchronous operations window is not negotiated.
Not implemented
- Relational queries and other SOP Class Extended Negotiation (sub-item 0x56 is ignored and never accepted). Combined date-time matching of a DA and a TM key as one range. Fuzzy name matching.
- Transcoding: objects are sent in their stored transfer syntax, or in an uncompressed one when they are uncompressed.
- The Composite Instance Root and Composite Instance Retrieve Without Bulk Data retrieve models.
- C-MOVE does not reuse destination associations across requests; each C-MOVE opens one.
Imaging: decode, render, export
DicomKit.Imaging is a separate NuGet package that decodes and renders DICOM images with DicomKit. Everything is managed code, so it runs the same on Windows, Linux and macOS with no native binaries.
dotnet add package DicomKit.Imaging
It uses the core's public API (DicomFile, DicomDataset, LazyDicomPixelData) and is versioned and licensed together with DicomKit.
Quick start
using DicomKit.Imaging;
using var image = await DicomImage.OpenAsync("ct.dcm"); // owns the file; Dispose closes it
Console.WriteLine($"{image.Width}x{image.Height}, {image.FrameCount} frame(s), {image.PhotometricInterpretation}, {image.TransferSyntax}");
// PNG of frame 0 with the dataset's first window preset
await using (var png = File.Create("ct.png"))
await image.RenderToPngAsync(png);
// Soft-tissue window for every later render
image.WindowCenter = 40;
image.WindowWidth = 400;
await using (var jpg = File.Create("ct-soft.jpg"))
await image.RenderToJpegAsync(jpg, quality: 90);
// One-off window, for one render
RgbaFrame bone = await image.RenderFrameAsync(0, new DicomRenderOptions { WindowCenter = 300, WindowWidth = 1500 });
byte[] rgba = bone.Pixels; // Width * Height * 4 bytes, R G B A
Synchronous versions exist for scripts and simple tools: DicomImage.FromFile(path), RenderFrame, RenderToPng, RenderToJpeg.
From a file or dataset you already have
using var file = await DicomFile.OpenAsync(path);
using var image = DicomImage.FromFile(file); // does not take ownership of the file
var image2 = DicomImage.FromDataset(dataset, DicomTransferSyntax.JpegBaseline); // in-memory dataset; say how pixels are encoded
The LUT, palette and functional-group attributes the pipeline reads are in the core dictionary, so Implicit VR files parsed with DicomFile.OpenAsync already read them with the right VR (sequences as SQ, LUT data as OW). DicomImagingTags.Register() is kept for compatibility and does nothing.
Thumbnails for a study
foreach (var path in Directory.EnumerateFiles(studyFolder, "*.dcm"))
{
using var image = await DicomImage.OpenAsync(path);
var thumb = await image.RenderThumbnailAsync(maxSize: 128); // fits 128 x 128, keeps the aspect ratio
await File.WriteAllBytesAsync(Path.ChangeExtension(path, ".thumb.jpg"), thumb.ToJpeg(80));
}
DicomRenderOptions.MaxWidth / MaxHeight do the same for any render. Images are only ever scaled down, by area averaging.
Multi-frame
for (var i = 0; i < image.FrameCount; i++)
{
var frame = await image.RenderFrameAsync(i);
// ...
}
Frames are decoded one at a time; nothing else is kept in memory between calls except small lookup tables.
Raw decoded samples
DecodeFrameAsync returns a frame in native layout, whatever the transfer syntax: little-endian samples, interleaved (planar configuration 0), 4:2:2 chroma upsampled, 1 byte per sample for Bits Allocated 1 and 8, 2 for 16, 4 for 32. Use it to convert compressed images to uncompressed ones or to feed your own processing.
DicomDecodedFrame f = await image.DecodeFrameAsync(0);
// f.Data: f.Info.DecodedFrameLength bytes
// f.Info.PhotometricInterpretation: the decoded samples' interpretation (YBR_FULL for a YBR_FULL_422 JPEG, RGB for YBR_RCT / YBR_ICT JPEG 2000)
Display pipeline
Grayscale (MONOCHROME1 / MONOCHROME2) follows PS3.4 N.2:
- Stored value: the sample is shifted by High Bit, masked to Bits Stored and sign-extended when Pixel Representation is 1.
- Modality LUT: the Modality LUT Sequence (0028,3000) when present, otherwise Rescale Slope / Intercept. For enhanced multi-frame objects the Pixel Value Transformation functional group of the frame (per-frame, then shared) is used.
ApplyModalityLut = falseskips it. - VOI LUT, chosen by
DicomRenderOptions.VoiSource(defaultAuto):- an explicit window:
DicomRenderOptions.WindowCenter/WindowWidth, elseDicomImage.WindowCenter/WindowWidth; if only one of the two is given, the other comes from the dataset preset; - the dataset's window preset
WindowIndex(0028,1050 / 0028,1051, or the frame's Frame VOI LUT functional group); - the VOI LUT Sequence item
VoiLutIndex(0028,3010); - otherwise the frame's own range (minimum to maximum modality value). An 8-bit image with no window, LUT or rescale is shown as stored.
VoiSource.VoiLutprefers the LUT over a window,VoiSource.Windownever uses a LUT,VoiSource.MinMaxalways stretches the frame range. Window functions (VOI LUT Function, 0028,1056, orDicomRenderOptions.VoiFunction) follow PS3.3 C.11.2.1.2 / C.11.2.1.3:Function Mapping to [0, 255] LINEAR(default)x <= c - 0.5 - (w-1)/2: 0; x > c - 0.5 + (w-1)/2: 255; else ((x - (c - 0.5)) / (w-1) + 0.5) * 255 LINEAR_EXACTx <= c - w/2: 0; x > c + w/2: 255; else ((x - c) / w + 0.5) * 255 SIGMOID255 / (1 + exp(-4 (x - c) / w)) VOI LUT entries are scaled by the LUT's output range (2^bits - 1; a table declared as 16 bits whose entries fit in 12 or 8 bits is treated as 12 or 8 bits, a common writer mistake).
- an explicit window:
- Presentation: MONOCHROME1 and Presentation LUT Shape (2050,0020)
INVERSEinvert the output (once, since DX pairs the two);DicomRenderOptions.Invertinverts it again.
For Bits Stored up to 16 the whole chain is folded into one lookup table per window, so a render is one table lookup per pixel (a 512 x 512 16-bit CT frame renders in well under a millisecond on a desktop CPU).
Colour images are not windowed:
| Photometric interpretation | Rendering |
|---|---|
RGB | as stored; samples over 8 bits are scaled to 8 |
YBR_FULL, YBR_FULL_422 | BT.601 full-range YCbCr to RGB (PS3.3 C.7.6.3.1.2); native 4:2:2 pairs (Y1 Y2 Cb Cr) upsampled |
YBR_PARTIAL_422, YBR_PARTIAL_420 | BT.601 studio range (Y 16..235) to RGB |
YBR_RCT, YBR_ICT | the JPEG 2000 decoder undoes the component transform, so the result is RGB |
PALETTE COLOR | red / green / blue tables (0028,1101-1103 and 1201-1203), 8 or 16-bit entries, any first mapped value, up to 65536 entries |
Planar configuration 1 (colour-by-plane) is handled for native data.
Transfer syntaxes
| Transfer syntax | UID | Decoder |
|---|---|---|
| Implicit / Explicit VR Little Endian, Explicit VR Big Endian, Deflated | 1.2.840.10008.1.2, .1.2.1, .1.2.2, .1.2.1.99 | native (the core parser inflates and byte-swaps) |
| RLE Lossless | 1.2.840.10008.1.2.5 | built in (PS3.5 Annex G) |
| JPEG Baseline (process 1) | 1.2.840.10008.1.2.4.50 | built in |
| JPEG Extended (processes 2 and 4, 8 and 12 bit) | 1.2.840.10008.1.2.4.51 | built in |
| JPEG Lossless (process 14) and JPEG Lossless SV1 | 1.2.840.10008.1.2.4.57, .4.70 | built in, predictors 1-7, point transform, 2-16 bit |
| JPEG-LS Lossless and Near-Lossless | 1.2.840.10008.1.2.4.80, .4.81 | CharLS.Managed (opens in a new tab) 0.8.0 (BSD-3-Clause) |
| JPEG 2000 Lossless and lossy (and JP2-wrapped codestreams) | 1.2.840.10008.1.2.4.90, .4.91 | CoreJ2K (opens in a new tab) 2.4.1 (BSD-3-Clause) |
The built-in JPEG decoder handles Huffman-coded sequential and lossless JPEG with restart intervals, interleaved and non-interleaved scans and any sampling factors. 4:2:0 and 4:2:2 chroma is upsampled with the same triangle filter as libjpeg, so results match libjpeg-turbo within the rounding of the IDCT (one or two levels). For three-component baseline JPEG the colour space is judged like libjpeg does (Adobe APP14 flag, JFIF header, component ids), so a file labelled RGB that actually holds JFIF YCbCr still renders correctly.
The decoder for an encapsulated frame is chosen from the transfer syntax for RLE, and from the codestream itself (JPEG SOF0/1/3, JPEG-LS SOF55, JPEG 2000 SOC or JP2 signature) for everything else, so a mislabelled transfer syntax still decodes. Frames are located with the core's frame map (Extended / Basic Offset Table, one fragment per frame, or start markers).
Not supported (an operation reports NotSupportedException with the reason):
- Progressive JPEG (SOF2, retired transfer syntaxes 1.2.840.10008.1.2.4.55 etc.), arithmetic-coded and hierarchical JPEG.
- HTJ2K (1.2.840.10008.1.2.4.201-203), JPEG XL, MPEG-2 / MPEG-4 / HEVC video.
- Float Pixel Data and Double Float Pixel Data, segmented palette colour tables, Samples per Pixel other than 1 and 3, Bits Allocated other than 1, 8, 16 and 32.
Your own codec
DicomFrameDecoders.Register("1.2.840.10008.1.2.4.201", new MyHtj2kDecoder());
sealed class MyHtj2kDecoder : IDicomFrameDecoder
{
public string? Decode(ReadOnlySpan<byte> encoded, DicomPixelInfo info, Span<byte> destination)
{
// write info.DecodedFrameLength bytes: little-endian samples, interleaved, info.BytesPerSample each
return null; // or the photometric interpretation of the output if the codec changed it, e.g. "RGB"
}
}
A registered decoder takes precedence over the built-in ones for that transfer syntax. The built-in decoders are available as DicomFrameDecoders.Rle, Jpeg, JpegLs and Jpeg2000 for direct use on codestreams.
Export
RgbaFrame.ToPng()/WritePng(stream): 8-bit grayscale PNG for grayscale renders, RGB otherwise.RgbaFrame.ToJpeg(quality)/WriteJpeg(stream, quality): baseline JPEG, grayscale or JFIF YCbCr 4:4:4.PngEncoder.Encode(...)writes Gray8, Gray16, Rgb24 and Rgba32 pixel buffers, for example 16-bit modality data fromDecodeFrameAsync.JpegEncoder.Encode(...)writes 8-bit gray, RGB or RGBA (alpha ignored) buffers.
Both writers are part of the package (PNG compression uses System.IO.Compression); there are no image-library dependencies.
Threading and memory
- Render and decode methods can be called concurrently on one
DicomImage. SettingWindowCenter/WindowWidthwhile another thread renders affects renders that start afterwards. - Keep the source open while you render:
DicomImage.OpenAsync(path)owns its file; withFromFile(DicomFile)orOpenAsync(Stream)the file or stream must stay open. - Each render rents one decode buffer from
ArrayPool<byte>.Sharedand allocates the RGBA result. Large multi-frame files are read one frame at a time throughLazyDicomPixelData.
Tests and fixtures
tests/DicomKit.Imaging.Tests builds synthetic images in code (gradients, known window values, palettes, YBR) and makes RLE, JPEG lossless and 8/12-bit DCT test data with small encoders written for the tests. JPEG baseline, JPEG lossless, JPEG-LS and JPEG 2000 are also checked against files made by independent encoders (Pillow / libjpeg-turbo, FFmpeg, OpenJPEG); Fixtures/generate_fixtures.py regenerates them and records their provenance.
DICOMweb: JSON, XML, client and ASP.NET Core server
Two packages add DICOMweb (DICOM PS3.18) to DicomKit:
| Package | Contents |
|---|---|
DicomKit.Web | DICOM JSON and XML serializers, streaming multipart/related reader/writer, DicomWebClient (QIDO-RS, WADO-RS, STOW-RS) |
DicomKit.Web.AspNetCore | app.MapDicomWeb(...) server endpoints over a pluggable IDicomWebStore, plus in-memory and file-system reference stores |
Both target .NET 8, 9 and 10 and use only the public API of DicomKit.
DICOM JSON (PS3.18 Annex F.2)
using DicomKit.Web.Json;
string json = DicomJson.Serialize(dataset); // one object
DicomDataset back = DicomJson.Deserialize(json);
List<DicomDataset> many = DicomJson.DeserializeList(jsonArray);
await DicomJson.SerializeAsync(stream, datasets); // streamed array, flushed every ~32 KiB
await foreach (var ds in DicomJson.DeserializeAsyncEnumerable(stream)) // one dataset buffered at a time
Handle(ds);
var options = new JsonSerializerOptions { Converters = { DicomJson.CreateConverter() } }; // inside your own models
How values map:
| VR | JSON |
|---|---|
| AE AS CS DA DT LO LT SH ST TM UC UI UR UT | strings; empty components are null; LT/ST/UT/UR are never split on \ |
| PN | objects { "Alphabetic", "Ideographic", "Phonetic" } |
| IS DS | JSON numbers (a DS keeps its original text when it is a valid JSON number). Values that are not numbers are kept as strings |
| US SS UL SL SV UV FL FD | JSON numbers; NaN and infinities as the strings "NaN", "Infinity", "-Infinity" |
| AT | strings "GGGGEEEE" |
| SQ | arrays of dataset objects |
| OB OD OF OL OV OW UN | InlineBinary (base64), or BulkDataURI |
Reading:
- Members may come in any order (
"vr"after"Value"is fine). Unknown members are ignored. - Numbers sent as strings are accepted. Integer VRs reject fractional or out-of-range values.
- JSON is Unicode. If the dataset's Specific Character Set cannot hold a value, the dataset is switched to UTF-8 (
ISO_IR 192) so no characters are lost. BulkDataURIbecomes aDicomBulkDataUriElement. Part 10 writers skip that type, so fetch the values withDicomWebClient.ResolveBulkDataAsync(dataset)before you save the dataset.- Malformed input throws
JsonException.
Options (DicomJsonOptions):
| Option | Default | Meaning |
|---|---|---|
BulkDataUriFactory | null | Called for each binary or deferred value with its tag path (7FE00010, 00081140/0/00091001). Return a URI, or null to inline the value |
MaxInlineBinaryLength | 64 MiB | Larger binary values that get no URI are written with their VR only. On read, larger InlineBinary values are rejected |
MaxSequenceDepth | 32 (max 64) | Deeper sequence nesting is rejected on read (guards the stack) |
SkipGroupLengths | true | (gggg,0000) attributes are not written |
WriteIndented | false | Indents the output for people to read |
Deferred values (DicomBulkElement, for example a 200 KB UT report or lazily parsed pixel data) are read from their source when they are inlined. Encapsulated pixel data is written only as a BulkDataURI.
DICOM XML (PS3.19 Native DICOM Model)
using DicomKit.Web.Xml;
string xml = DicomXml.Serialize(dataset, new DicomJsonOptions { BulkDataUriFactory = c => baseUrl + c.Path });
DicomDataset ds = DicomXml.Deserialize(xml);
DicomDataset fromStream = await DicomXml.DeserializeAsync(stream, maxBytes: 16 << 20);
XML uses the same options as JSON. PN values are split into FamilyName, GivenName, MiddleName, NamePrefix and NameSuffix. The reader prohibits DTDs, entity expansion and external resolution, so XXE and "billion laughs" input fails.
Client
using DicomKit.Web;
using DicomKit.Web.Client;
// With IHttpClientFactory (recommended):
builder.Services.AddDicomWebClient(o =>
{
o.BaseAddress = new Uri("https://pacs.example.org/dicom-web/");
o.OperationTimeout = TimeSpan.FromMinutes(10); // covers streaming the whole response
o.MultipartLimits.MaxPartBytes = 2L << 30;
})
.AddHttpMessageHandler(() => new BearerTokenHandler(ct => tokenSource.GetTokenAsync(ct)));
// Or directly:
var client = new DicomWebClient(new HttpClient { BaseAddress = new Uri("https://pacs.example.org/dicom-web/") });
Authentication
Put authentication in the HttpClient handler pipeline. BearerTokenHandler asks your delegate for a token on every request, so the delegate can cache tokens and refresh them before they expire (with MSAL, Azure.Identity, or your own OAuth client). It sets Authorization: Bearer <token>. For Basic auth, client certificates or API keys, write any DelegatingHandler, or configure the primary handler (ConfigurePrimaryHttpMessageHandler).
QIDO-RS
var query = new QidoQuery { Limit = 50, Offset = 0, FuzzyMatching = true }
.Match(DicomTag.PatientName, "SMITH*")
.Match(DicomTag.StudyDate, "20260101-20260131")
.Match("ModalitiesInStudy", "CT")
.Include(DicomTag.StudyDescription); // or IncludeAllFields = true
await foreach (var study in client.SearchStudiesAsync(query, ct)) ...
await foreach (var series in client.SearchSeriesAsync(studyUid, ct: ct)) ...
await foreach (var instance in client.SearchInstancesAsync(studyUid, seriesUid, ct: ct)) ...
A 204 No Content response yields no results.
WADO-RS
// Instances stream one part at a time. Each part is parsed as it arrives and large values spool to temp files,
// so memory does not grow with the study. Dispose each file.
await foreach (var file in client.RetrieveStudyAsync(studyUid, transferSyntax: "*", ct: ct))
using (file) await file.SaveAsync(Path.Combine(dir, file.Dataset.GetString(DicomTag.SOPInstanceUID) + ".dcm"), ct: ct);
using var one = await client.RetrieveInstanceAsync(studyUid, seriesUid, sopUid);
await foreach (var meta in client.RetrieveStudyMetadataAsync(studyUid)) ... // DICOM JSON, bulk data as URIs
await client.ResolveBulkDataAsync(meta); // fetch the URIs when you need them
await foreach (var frame in client.RetrieveFramesAsync(studyUid, seriesUid, sopUid, [1, 2, 3]))
Decode(frame.FrameNumber, frame.Data, frame.TransferSyntaxUid);
byte[] bulk = await client.RetrieveBulkDataAsync(bulkDataUri);
await client.RetrieveBulkDataAsync(bulkDataUri, destinationStream);
await using var jpeg = await client.RetrieveRenderedAsync(studyUid, seriesUid, sopUid, frameNumber: 1, mediaType: "image/jpeg");
The transferSyntax parameter works as follows: null asks for the PS3.18 default (Explicit VR Little Endian), "*" accepts the stored encoding, and a UID asks for that specific syntax.
STOW-RS
StowResult r = await client.StoreFilesAsync(Directory.EnumerateFiles(dir, "*.dcm")); // read while sending
r = await client.StoreAsync(files, studyInstanceUid: studyUid); // DicomFile objects
r = await client.StoreStreamsAsync(streams);
if (!r.IsSuccess)
foreach (var f in r.Failed) Console.WriteLine($"{f.SopInstanceUid}: 0x{f.FailureReason:X4}");
The request body is a streamed MultipartRelatedContent, sent chunked with no Content-Length. It never holds the study in memory. Status 200, 202 and 409 return a StowResult. Any other failure throws DicomWebException, which carries StatusCode, ResponseBody and, if the server sent one, ResponseDataset.
Timeouts and cancellation
Every method takes a CancellationToken, and cancelling it stops the response stream. HttpClient.Timeout covers only the wait for response headers, because responses are read with ResponseHeadersRead. Use DicomWebClientOptions.OperationTimeout to limit an entire retrieve.
Limits
| Option | Default |
|---|---|
MultipartLimits.MaxPartBytes | 4 GiB |
MultipartLimits.MaxTotalBytes | unlimited |
MultipartLimits.MaxParts | 100,000 |
MultipartLimits.MaxHeaderBytes | 16 KiB |
MaxJsonResponseBytes | 256 MiB |
JsonOptions.MaxSequenceDepth | 32 |
A response that exceeds a limit throws MultipartLimitExceededException. UIDs are checked before they go into a URL: only digits and dots, at most 64 characters.
Server (ASP.NET Core)
using DicomKit.Web.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDicomKit(o => o.Licensing.LicenseKey = builder.Configuration["DicomKit:LicenseKey"]);
builder.Services.AddDicomWebServer(o =>
{
o.MultipartLimits.MaxTotalBytes = 2L << 30; // also set as the request body size limit for STOW
o.MultipartLimits.MaxPartBytes = 512L << 20;
o.MaxQueryLimit = 500;
o.PublicBaseUrl = new Uri("https://pacs.example.org/dicomweb"); // when behind a reverse proxy
});
builder.Services.AddFileSystemDicomWebStore(@"D:\archive"); // or AddInMemoryDicomWebStore(), or your own IDicomWebStore
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapDicomWeb("/dicomweb").RequireAuthorization();
app.Run();
Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /studies, /series, /instances, /studies/{s}/series, /studies/{s}/instances, /studies/{s}/series/{se}/instances | QIDO-RS. JSON by default, or multipart/related; type="application/dicom+xml". Returns 204 when nothing matches. Sends a Warning: 299 header when more results exist |
| GET | /studies/{s}, /studies/{s}/series/{se}, /studies/{s}/series/{se}/instances/{i} | multipart/related; type="application/dicom". A single application/dicom response is available for one instance |
| GET | .../metadata (study, series, instance) | Bulk values above BulkDataInlineThreshold (1 KiB), and all deferred values, come back as BulkDataURI |
| GET | .../instances/{i}/frames/{1,2,...} | Native data as application/octet-stream. Encapsulated data in its own media type (image/jpeg, image/jp2, image/jls, ...) |
| GET | .../instances/{i}/bulk/{tag path} | The target of the metadata BulkDataURIs. Multipart by default, or single-part with Accept: application/octet-stream |
| GET | .../rendered | 406: the server has no image codecs |
| POST | /studies, /studies/{s} | STOW-RS. Accepts application/dicom parts. Answers 200, 202 (partial) or 409 (none stored) with a Store Instances Response |
Transfer syntaxes: the server sends stored data unchanged when the request asks for * or for the stored syntax. Native data can be re-encoded between Explicit and Implicit VR Little Endian. Compressed data is never decoded. If no syntax is asked for, compressed data is sent as stored. If a syntax the server cannot produce is asked for, the server answers 406.
Hardening
- STOW bodies are capped by part size, total size, part count and per-part header size. Defaults: 2 GiB per part, 4 GiB per request, 10,000 parts, 16 KiB of headers per part. A body over a cap gets 413. A malformed body gets 400.
- Each part is parsed with the core parser (
ServerOptions.ParserOptions, with its element, depth, spool and inflate limits). An instance that is incomplete or is missing its UIDs is reported as failed with reason0xC000. - A UID in a path must be a valid DICOM UID, otherwise the request gets 400. The file store builds file names only from validated UIDs, checks that every path stays under its root, and does not follow reparse points or symlinks when it indexes.
- JSON nesting is limited (
JsonOptions.MaxSequenceDepth). The XML reader prohibits DTDs. - QIDO results are capped by
MaxQueryLimit. Frame requests are capped byMaxFramesPerRequest.
Writing a store
public sealed class MyStore : IDicomWebStore
{
public IAsyncEnumerable<DicomDataset> SearchAsync(DicomWebSearchRequest request, CancellationToken ct) => ...;
public IAsyncEnumerable<DicomWebInstance> FindInstancesAsync(string study, string? series, string? sop, CancellationToken ct) => ...;
public ValueTask<Stream?> OpenInstanceAsync(DicomWebInstance instance, CancellationToken ct) => ...; // seekable preferred
public ValueTask<DicomWebStoreOutcome> StoreAsync(DicomFile file, CancellationToken ct) => ...;
}
SearchAsync returns every match with as many attributes as possible. The endpoint then projects each result to the requested attributes (DicomWebQueryMatcher.Project), applies offset and limit, and adds RetrieveURL. DicomWebQueryMatcher.Matches provides PS3.4 matching for stores that filter in memory: universal, single-value, wildcard, UID list, DA/TM/DT range, and fuzzy PN. To build on an in-memory index, derive from IndexedDicomWebStore.
Not supported (yet)
- Rendered resources: the server answers 406. The client works with any server that can render.
- Transcoding of compressed transfer syntaxes: the server does not decode compressed data.
- QIDO sequence matching (
00081140.00081150=...): the server answers 400. - **STOW-RS with
application/dicom+jsonor XML metadata plus bulk data parts**: the server answers 415. The client sends Part 10 only. - UPS-RS, thumbnails and the capabilities (OPTIONS / WADL) resource.
Converting PDF and images to DICOM
DicomKit.Conversion.DicomConverter wraps a PDF, a JPEG, a PNG or a BMP in a DICOM instance, and gets the PDF or the JPEG back out. A common use: attach a scanned requisition or a PDF report to the patient's study.
using DicomKit.Conversion;
using DicomKit.IO;
// Attach a PDF to an existing study: patient and study attributes are copied from one of its images.
using var image = await DicomFile.OpenAsync("study/IM0001.dcm");
using var doc = await DicomConverter.FromPdfAsync("requisition.pdf", new DicomConversionOptions
{
CopyFrom = image.Dataset, // Patient + General Study attributes, Study Instance UID
SeriesDescription = "Scanned documents",
SeriesNumber = 900,
});
await doc.SaveAsync("requisition.dcm");
// A JPEG photo for a new study, the JPEG kept byte for byte (no re-compression).
using var photo = await DicomConverter.FromJpegAsync("wound.jpg", new DicomConversionOptions
{
PatientName = "DOE^JANE", PatientId = "12345", PatientBirthDate = new DateOnly(1980, 2, 3), PatientSex = "F",
AccessionNumber = "A1001", StudyDescription = "Wound care",
});
await photo.SaveAsync("wound.dcm");
// Any supported input, chosen by content: PDF, JPEG, PNG or BMP.
using var any = await DicomConverter.FromImageAsync(path, options);
// Several pages as one multi-frame instance (all JPEG, or all PNG/BMP, same size).
using var pages = await DicomConverter.FromFilesAsync(["page1.jpg", "page2.jpg"], options);
// And back.
byte[] pdf = await DicomConverter.ExtractPdfAsync(doc);
byte[] jpeg = await DicomConverter.ExtractJpegAsync(photo); // frame 0; ExtractJpegAsync(file, frame) for others
The ReadOnlyMemory<byte> overloads (FromPdf, FromJpeg, FromJpegFrames, FromImage, FromImages) are synchronous and keep a reference to your buffer instead of copying it, so do not change it while you use the result. DicomConverter.DetectFormat(bytes) tells you what an input is.
What is produced
| Input | SOP Class | Transfer syntax | Pixel / document data |
|---|---|---|---|
Encapsulated PDF Storage 1.2.840.10008.5.1.4.1.1.104.1 | Explicit VR Little Endian | Encapsulated Document (0042,0011) OB | |
| JPEG baseline (SOF0, 8-bit) | Secondary Capture Image Storage …1.1.7 | JPEG Baseline 1.2.840.10008.1.2.4.50 | one fragment, the JPEG as-is |
| JPEG extended (SOF1, 8/12-bit) | Secondary Capture | JPEG Extended …4.51 | one fragment |
| JPEG lossless (SOF3), predictor 1 | Secondary Capture | JPEG Lossless SV1 …4.70 | one fragment |
| JPEG lossless (SOF3), predictor 2-7 | Secondary Capture | JPEG Lossless …4.57 | one fragment |
| PNG, BMP | Secondary Capture | Explicit VR Little Endian | native, OB (8-bit) or OW (16-bit) |
| several JPEG / PNG / BMP | Multi-frame Grayscale Byte …7.2, Grayscale Word …7.3 or True Color …7.4 SC | as above | one fragment per frame and a full Basic Offset Table, or native frames |
The JPEG is never decoded: the converter reads its marker segments only (SOF for size, precision and components, SOS for the lossless predictor, JFIF / Adobe APP14 for the colour space). The fragment is the original file plus at most one 0x00 padding byte, and ExtractJpegAsync returns the original bytes.
Attributes
Common to every instance (unknown Type 2 attributes are present and empty):
| Module | Attributes |
|---|---|
| SOP Common | SOP Class/Instance UID (generated under 2.25), Instance Creation Date/Time, Specific Character Set ISO_IR 192 when any text is not ASCII |
| Patient | Patient Name, Patient ID, Birth Date, Sex (Type 2); Issuer of Patient ID when given |
| General Study | Study Instance UID, Study Date/Time, Referring Physician, Study ID, Accession Number (Type 2); Study Description when given |
| General / Encapsulated Document Series | Modality (DOC for PDF, OT for images), Series Instance UID, Series Number (default 1), Series Description when given |
| General Equipment | Manufacturer (default "PrologicSign Technology"), Institution Name when given |
| SC Equipment | Conversion Type (default WSD), Secondary Capture Device Manufacturer / Model ("Plgic.Dicom") / Software Versions |
| Content | Instance Number (default 1), Content Date/Time (default now, ContentDateTime to set it) |
Encapsulated PDF adds Burned In Annotation YES, Document Title (the file name without extension when converting a path, or DocumentTitle), Concept Name Code Sequence (empty, Type 2), Acquisition DateTime (empty), MIME Type of Encapsulated Document application/pdf, Encapsulated Document Length (the unpadded size), and the Encapsulated Document padded to even length. The input must contain %PDF- within its first 1024 bytes.
Secondary Capture images add Patient Orientation (empty), Date/Time of Secondary Capture, Burned In Annotation (YES by default; set BurnedInAnnotation = false for "NO"), and the Image Pixel module: Samples per Pixel, Rows, Columns, Bits Allocated/Stored, High Bit, Pixel Representation 0, Planar Configuration 0 (colour only). Photometric Interpretation follows PS3.5 8.2.1:
| JPEG | Photometric Interpretation |
|---|---|
| 1 component | MONOCHROME2 |
| lossy colour, chroma subsampled (4:2:2, 4:2:0) | YBR_FULL_422 |
| lossy colour, not subsampled (4:4:4) | YBR_FULL |
| lossy colour marked as untransformed RGB (Adobe APP14 transform 0, or component IDs R/G/B without JFIF) | RGB |
| lossless colour | RGB |
Lossy JPEG also gets Lossy Image Compression 01, Lossy Image Compression Ratio (uncompressed size over JPEG size) and Method ISO_10918_1; lossless JPEG gets Lossy Image Compression 00. Precision above 8 bits gives Bits Allocated 16 with Bits Stored equal to the JPEG precision.
Multi-frame instances add Number of Frames, Frame Increment Pointer → Page Number Vector (0018,2001) = 1\2\…, and for grayscale Presentation LUT Shape IDENTITY with Rescale Intercept 0, Slope 1, Type US.
Copying from an existing study
CopyFrom copies Patient Name, Patient ID, Issuer of Patient ID, Birth Date, Sex, Age, Size, Weight, Study Instance UID, Study Date/Time, Study ID, Study Description, Accession Number and Referring Physician from any dataset. The new instance gets its own Series and SOP Instance UIDs. Values set on the options win over copied ones. Equipment, modality and institution are not copied: they describe the conversion, not the original acquisition.
Without CopyFrom or StudyInstanceUid a new study is created and its date/time default to the content date/time. With only StudyInstanceUid, Study Date/Time stay empty unless StudyDateTime is set.
Validation
ArgumentException for invalid options: a UID that is not digits and dots (≤ 64 characters), Patient Sex other than M/F/O, an empty Conversion Type or Modality, or text longer than its VR allows (SH 16, LO 64, CS 16 characters). InvalidDataException for input that is not what it claims (not a PDF, not a JPEG, truncated or malformed headers, a broken PNG/BMP). NotSupportedException for valid input DICOM cannot carry unchanged, with a message that says what to do instead.
PNG and BMP
Decoded by small managed decoders in the library (no System.Drawing, no native code), stored uncompressed:
- PNG: every colour type and bit depth, Adam7 interlacing, palettes and tRNS. Alpha is composited over white. Grayscale (with or without alpha) becomes
MONOCHROME2, 8-bit, or 16-bit for 16-bit input; colour and palette images become 8-bitRGB. Bit depths below 8 are scaled to 0-255. Chunk CRCs are not checked. - BMP: uncompressed 1, 4, 8, 24 and 32-bit (BI_RGB, or BI_BITFIELDS with the standard 8-8-8 masks), bottom-up or top-down. A palette whose entries are all gray becomes
MONOCHROME2; the 32-bit alpha/X byte is ignored.
Limitations
- Progressive JPEG (SOF2) is rejected: DICOM has no transfer syntax for it and the converter never re-compresses. Save it as baseline JPEG or PNG first. Arithmetic-coded (SOF9-SOF15), hierarchical (SOF5-SOF7), CMYK / 4-component and 2-component JPEG, and JPEGs whose height is only in a DNL marker are rejected too.
- EXIF orientation is not applied. A phone photo whose EXIF says "rotate 90°" is stored as encoded; rotating it would mean decoding and re-compressing.
JpegInfo.Parse(bytes).ExifOrientationtells you the value if you want to rotate beforehand. - No JPEG 2000, JPEG-LS, GIF, TIFF or HEIC input; no PDF/A or PDF validity check beyond the
%PDF-header. - Inputs are read fully into memory; PDFs and images up to the 2 GB array limit.
- Multi-frame instances need frames with the same size, process, precision and colour space; 12-bit colour and sub-8-bit grayscale have no multi-frame SC class and are rejected for more than one frame. With Conversion Type DF, SD or SI, PS3.3 C.8.6.3 may also require Nominal Scanned Pixel Spacing (0018,2010), which the converter cannot know: set it on the returned dataset if you use those types.
- JPEG Extended (
.4.51) and JPEG Lossless (.4.57) are not in the core transfer syntax list, sofile.TransferSyntax.Namereads "Unknown (…)" for them; the UID, encoding and round trip are correct. - The dictionary keywords for the attributes above (Encapsulated Document, Conversion Type, Burned In Annotation, …) are registered with
DicomDictionarythe first time the converter runs. A process that only reads converted files (e.g. the DumpFile sample) prints those tags without keywords; values and VRs are unaffected for Explicit VR files.
Sample
The ConvertToDicom sample is a command-line front end (run it from the folder of the samples download):
dotnet run --project ConvertToDicom -- scan.pdf scan.dcm --patient-name "DOE^JANE" --patient-id 12345
dotnet run --project ConvertToDicom -- photo.jpg photo.dcm --from-dicom study-image.dcm
dotnet run --project ConvertToDicom -- --extract scan.dcm scan-out.pdf
Large files
DicomKit parses multi-gigabyte DICOM files with memory bounded by the metadata, not the file size.
Parse a 1 GB file with near-zero memory
using DicomKit.IO;
// Metadata is parsed; Pixel Data is recorded as an offset/length reference and never read.
using var file = await DicomStreamParser.ParseAsync(path, ct: ct); // or ParseMetadataAsync to stop after Pixel Data
var pixels = file.GetLazyPixelData(); // Rows, Columns, FrameCount, FrameLength, IsEncapsulated, ...
// One frame, into your buffer / a pooled buffer / a stream:
await pixels.ReadFrameAsync(42, myBuffer, ct);
using (var frame = await pixels.RentFrameAsync(42, ct)) Decode(frame.Memory.Span);
await pixels.CopyFrameToAsync(42, outputStream, ct);
// Every frame through ONE reused pooled buffer:
await foreach (var f in pixels.GetFramesAsync(ct))
Process(f.Index, f.Data.Span); // f.Data is overwritten by the next frame; copy it if you keep it
Keep the DicomFile open while reading frames: it owns the file handle the deferred values read from. When you parse a seekable Stream yourself, keep that stream open too: deferred values are read back from it.
Memory model
| In memory | Deferred (left in the source until you ask) |
|---|---|
| Element headers, decoded once from spans over one pooled 64 KiB window | Pixel Data (native: one DicomLazyValue; encapsulated: Basic Offset Table + one DicomLazyValue per fragment, found by reading only the 8-byte item headers) |
Values below 80 KiB (DicomParserOptions.MaxInMemoryValueLength, clamped to MaxLohSafeValueLength = 81,920 bytes < 85,000), packed into shared 32 KiB arena chunks (values over 8 KiB get their own small-object-heap array) | Every value of 80 KiB or more, whatever its VR - including text: a 200 KB UT becomes a DicomBulkElement. GetString returns null for it; use await dataset.GetStringAsync(tag) |
Datasets, sequences, items and DicomParseIssue records | OB/OW/OF/OD/OL/OV/UN values of 64 KiB or more (LazyLoadThreshold) |
- No LOH allocation while parsing. The window never grows beyond its pooled 64 KiB buffer (values between 64 and 80 KiB are copied straight from the stream into their destination); skip, spool and copy buffers are pooled 64 KiB arrays.
- Files are opened through one
FileBulkSource(RandomAccess,FileOptions.Asynchronous | RandomAccess): the parse and every later frame read share that handle, frame reads are positional and thread-safe, and skipping 1 GB of pixels is a position bump. A bufferedFileStreamwould add a second copy of every header byte and need a second handle for frame reads. - Seekable streams (a
FileStream, aMemoryStreamthat does not expose its buffer, ...) are treated like files: deferred values are recorded by offset and read back from the stream (positionally through the handle for aFileStream, seek + read under a lock otherwise). Nothing is spooled, andParseMetadataAsynckeeps Pixel Data readable. The stream is not disposed by theDicomFile. - Non-seekable streams (network, deflate) spool deferred values to a delete-on-close temp file (
SpoolDirectory), at mostMaxSpoolBytesper parse.ParseMetadataAsyncon such a stream spools nothing: Pixel Data is recorded with its offset and length but is not readable (DicomLazyValue.IsReadable == false). - Native frame size is Rows x Columns x Samples per Pixel x Bits Allocated / 8, except
YBR_FULL_422/YBR_PARTIAL_422, which store two samples per pixel (FrameLength);UncompressedFrameLengthis the upsampled size. - Frames are only materialised when you ask:
ReadFrameAsyncfills your buffer,RentFrameAsyncrents fromArrayPool<byte>.Sharedor a pool you pass,OpenFrameStream/CopyFrameToAsyncstream through 64 KiB.ArrayPool.Sharedallocates the first array of each size bucket (on the LOH from 85,000 bytes) and trims idle arrays on Gen2 GCs; on older runtimes, arrays over 1 MB were never pooled. For frames over 1 MB, passArrayPool<byte>.Create(maxFrameBytes, n)for a steady state with no allocation. - Malformed input never throws. Each defect becomes a
DicomParseIssue, is routed to theErrorLogschannel, and is replaced by a placeholder item whoseRecoverydescribes the repair. Lengths are checked against the enclosing item, the end of the stream and the limits below. Cancellation throwsOperationCanceledExceptionand releases the file handle and any temp spool. - Defined-length items are hard boundaries, also for undefined-length content inside them: a sequence, item or fragment list whose delimiter is missing ends at the enclosing item's boundary (
MissingSequenceDelimiter), a fragment that overruns it is clamped (LengthExceedsParent), and parsing continues with whatever follows the item. - Skipped content is walked, not scanned. A sequence nested too deeply, or a stray undefined-length item, is skipped by following its headers (nested sequences, items and fragment lists, with their own delimiters), so the delimiters inside it cannot close the parent sequences early. Only content that does not parse falls back to a scan for the next Sequence Delimitation Item.
- Explicit VR Big Endian: a UN value holding a sequence (undefined length, or a dictionary SQ sent as UN) is decoded as Implicit VR Little Endian, as PS3.5 6.2.2 requires. Deferred Big Endian values keep
IsBigEndianand are swapped when read or re-written (AT as two 16-bit numbers).
Limits
DicomParserOptions caps what one parse may cost. DicomParserOptions.Default returns a new instance on every access, so changing it affects only the caller that holds it. A limit that ends the parse leaves IsComplete == false; everything parsed before it is kept.
| Option | Default | When exceeded |
|---|---|---|
MaxSequenceDepth | 32, clamped to 0..256 (MaxSafeSequenceDepth) | MaxDepthExceeded; the too-deep sequence is skipped and parsing goes on. Nesting also stops when the thread's stack runs low. |
MaxFragments | 1,048,576 per element | TooManyFragments; the parse stops. |
MaxElements | 10,000,000 elements, items and fragments per parse | TooManyElements; the parse stops. |
MaxInflatedBytes | 4 GiB (deflated syntax) | InflatedSizeExceeded; nothing past the cap is inflated, the parse stops. |
MaxSpoolBytes | 64 GiB per parse (non-seekable input) | SpoolLimitExceeded; the value that would cross the cap is not written, the parse stops. |
MaxIssues | 500 | Further issues are counted, not recorded (IssuesSuppressed). |
Writing
DicomFile.SaveAsync(path) writes to a uniquely named <path>.<guid>.partial next to the target and renames it over the target, so readers never see a half-written file and concurrent saves of the same path do not collide (the last rename wins). Like DicomFile.OpenAsync, the public DicomWriter entry points and LazyDicomPixelData.Create / GetLazyPixelData require a valid license.
Measured
LargeFileTests ([Trait("Category","LargeFile")], part of the default suite). Debug build, .NET 9.0, Windows 10, local SSD. "Allocated" is GC.GetTotalAllocatedBytes(true); "LOH" is the growth of objects in use on the LOH between two full blocking GCs (size minus fragmentation), taken after a warm-up run.
| Scenario | Before | After |
|---|---|---|
| Full parse of a 1 GiB file (512 frames of 1024x1024x16-bit, 1,073,742,830 bytes) | - | 0.6 ms, 60 KB allocated, 0 B LOH |
ParseMetadataAsync of the same file | - | 60 KB allocated, 0 B LOH |
GetFramesAsync over all 512 frames (1 GiB read), custom pool | - | 0.57 s, 905 KB allocated (about 1.7 KB per frame, test assertions included), 0 B LOH |
GetFramesAsync over all 512 frames, ArrayPool.Shared (warm) | - | 0.50 s, 223 KB allocated, 0 B LOH |
| Parse with a 200 KB UT value | 200,024 B LOH per parse (462,192 B cold) | 0 B LOH, 68 KB allocated |
| Non-seekable stream, 4 MB pixel data (spooled) | 1,048,600 B LOH (1 MiB spool buffer) | 0 B LOH, 76 KB allocated |
| Cancel a 1 GiB non-seekable parse after 150 ms | spool handle leaked until finalization | OperationCanceledException about 25 ms after the token fired; spool deleted, file openable exclusively |
| Mutation fuzzing of length fields (5,000 iterations, 4 parse modes) | - | no throw, no hang, about 3 s |
Writing the 1 GiB test file from a synthetic source takes about 5 s, and the whole suite runs in about 10 s.
Licensing and activation
Add your license key
Copy the key from your licenses and give it to the library in any of these ways:
// ASP.NET Core / generic host
builder.Services.AddDicomKit(o => o.Licensing.LicenseKey = builder.Configuration["DicomKit:LicenseKey"]);
// or anywhere
await LicenseManager.Default.ActivateAsync("PLGIC-PRO-XXXXX-XXXXX-XXXXX-XXXXX");
// or the environment
DICOMKIT_LICENSE_KEY=PLGIC-PRO-XXXXX-XXXXX-XXXXX-XXXXX
With AddDicomKit (ASP.NET Core, worker services) the key is activated online at startup. In a console or desktop app, call await DicomKitLicense.SetAsync(key) once at startup; the license is then saved on the machine.
Or use the activation tool
No code change, or a machine without internet? Activate with the DicomKit activation tool, as the user that runs your application:
dotnet tool install -g DicomKit.Activator
dicomkit-activate activate PLGIC-PRO-XXXXX-XXXXX-XXXXX-XXXXX
Download the Windows activation tool, paste the key and press Activate. It shows this computer's machine id and saves the license to C:\ProgramData\Plgic\license.lic.
Offline (air-gapped) activation
- On the offline machine call
LicenseManager.Default.CreateOfflineActivationRequest()and save the request (it starts withPLGICREQ1.; it contains no secrets or patient data). - On any connected computer, open the license in the portal, choose Offline activation and upload or paste the request. You receive a signed
.licfile. - Carry the
.licfile back and callLicenseManager.Default.ActivateOffline(path). Renewals work the same way.
Moving to a new machine
Each license covers a number of machines (seats). Free a seat from the license page when you retire a server, then activate on the new one.
When a license ends. A paid Pro or Enterprise license that lapses or is revoked never stops running work: DicomKit keeps the Community features and refuses only Pro and Enterprise features until you renew. A free Developer license is different: when it expires or is revoked, DicomKit stops as if no key were installed, and the error tells you to request a free renewal under My licenses or to buy a plan.
On this page
Resources
Questions? Contact support — we reply within 24 hours, every day.