v1.4.0
Correctness release. Everything below was found by running this library against
pydicom's test corpus or a live peer — not by its own tests, which passed
throughout.
Fixed
-
Four N-DIMSE requests named their target with the wrong element. N-GET,
N-SET, N-ACTION and N-DELETE identify what they act on with Requested SOP
Instance UID(0000,1001); this library sent Affected SOP Instance UID
(0000,1000)— the element for messages that create or report on an instance
— and(0000,1001)did not exist in the codebase at all. Its own SCP read
back the same wrong tag, so every test passed while no other implementation
could see a target: pynetdicom answered a well-formed N-ACTION with "Received
unexpected N-ACTION service message" and aborted the association. Found by
running against pynetdicom, which now happens in CI. -
Multi-frame images reported a single frame.
NumberOfFramesis IS, and
PS3.5 §6.2 pads a value to an even length with a trailing space, so"2 "
failedstrconv.Atoiand the count silently fell back to its default of 1.
For compressed data that meant every frame after the first was discarded with
no error at all. Text values now have their padding stripped, as the standard
says they should. -
The deflated transfer syntax UID was wrong in
compress, recorded as
1.2.840.10008.1.2.4.1— a UID in the JPEG arc that is not a transfer syntax.
Real deflated files fell through to "unknown transfer syntax". -
The RLE encoder produced frames nothing could read. It emitted no segment
header, and this package's decompressor accepted that because it had the
matching defect. Its PackBits runs were also capped out of range: a replicate
run at 256 emitted a control byte meaning a two-byte literal, and a literal
run at 129 emitted the no-op marker. -
Explicit VR Big Endian files could not be read, and files written as big
endian could not be read by anything else. Three separate defects:- The short-form value length was assembled by hand as little endian at two
sites infilereader, ignoring the byte order the reader had already been
given.MR_small_bigendian.dcmparsed to 1 element; pydicom reads 72. - Values themselves stayed in big endian once the lengths were fixed, so
BitsAllocated of 16 read back as 4096 — the same bits reversed. Values are
now normalised to little endian as they are parsed, soDatasetneeds no
byte-order concept and everything downstream can assume one order. - The write side had no inverse. A file round-tripped through the library
declared big endian while holding little endian values, andfilewriter
wrote the file meta header in the data set's byte order although PS3.10
§7.1 requires it always be Explicit VR Little Endian — producing a header
whose first tag read back as(0200,0000).
Verified:
MR_small_bigendian.dcmnow parses to 72 elements and decodes to
pixels identical to pydicom's; a file written by this library and re-read in
pydicom matches on both element values and pixel data. - The short-form value length was assembled by hand as little endian at two
-
C-CANCEL aborted the association.
SCU.Cancelsent a well-formed
C-CANCEL-RQ and the SCP had no case for it, so it fell to the default branch,
which aborts. Cancelling did not merely fail — it tore down the connection and
discarded results the requestor had already received. It is now dispatched and
the association survives. -
The
fileinput to the coverage upload was silently ignored in CI, and
govulncheckwas installed from@latest, which broke the build when the
tool raised its own Go requirement. -
Text was returned as stored bytes, so most of the world's names came back as
mojibake. Decoding was reachable only throughDecodePersonNameand
DecodeTextValue, so a caller who did not know to ask got Greek, Hebrew,
Japanese or plain accented Latin as raw bytes. Two of pydicom's seventeen
charset fixtures matched its reading; all seventeen do now. Values are decoded
to UTF-8 on read and Specific Character Set is rewritten toISO_IR 192so the
data set stays self-consistent, item-scoped character sets included. -
A data set with no file meta header read as empty, with no error. PS3.5
§10.1 makes Implicit VR Little Endian the default, and that was assumed. An
explicit VR stream read that way takes the VR characters as part of the length
—(0008,0005) CS 10becomes a length of 676675 — which runs past the end of
the file, so the element is dropped and every one after it with it. pydicom
reads 24 elements from each of its two headerless fixtures; this read none. The
encoding is now taken from the first element. -
A truncated sequence item discarded the whole sequence. The complete items
before it were thrown away to punish a defect they had no part in;
DICOMDIR-nooffsetlost 51 good records because its 52nd is cut short. -
De-identification covered 38 of the 655 attributes PS3.15 names. Measured
across pydicom's corpus, 60 identifying attributes kept their original values
in files reported as de-identified — Patient's Sex in 49 of 69 files, Age in
25, Weight in 14. Sequences were never descended into, attributes named by a
range of groups (curve and overlay data) could not be looked up at all, and the
UID action silently did nothing when it met a sequence, leaving every
Referenced SOP Instance UID intact in 18 files — each one a link from the
de-identified object back to its original. Zero remain. -
filesetsearched nothing and counted wrongly.FindByModality,
FindByPatient,FindByStudyInstanceUIDandFindBySeriesInstanceUIDignored
their argument and returned every record;GetStatisticsreported the file
count as the patient, study and series counts;ScanDirectoryandAddFile
never parsed the files they listed, so nothing above them had anything to work
with; andGenerateDICOMDIRreturned an empty data set. -
The Huffman table builder dropped a bit at every empty code length.
Canonical codes lengthen at each length, including one no code has. Skipping
that shift left every longer code a bit short, so the table decoded a different
symbol than the encoder wrote. It needs an interior gap to show and no lossless
fixture had one; JPEG Lossless was wrong on any stream whose tables skip a
length. -
Byte order was not swapped for the 64-bit value representations, so a big
endian file carrying an SV, UV or OV kept its values in the wrong order and
said nothing about it. -
NIfTI export produced a file no reader could open. The header was 348 zero
bytes with the magic string at the end, sosizeof_hdrwas 0 and nibabel
answered "Cannot work out file type" while the command reported success. The
pixel data was the stored value rather than the decoded one, so a compressed
instance contributed a codestream described as an array of samples.
Added
-
A DICOM Conformance Statement (CONFORMANCE.md), in the
structure of PS3.2: SOP classes per role, transfer syntaxes negotiated versus
those readable from a file, extended negotiation, configuration, character
sets, enforced limits, and a plainly stated list of limitations. Every UID and
exported symbol it names was checked against the code.One thing it makes explicit that was easy to miss: the SCP negotiates only
Implicit and Explicit VR Little Endian by default. A big endian, deflated or
compressed data set is read correctly from a file but is not accepted on the
wire unless the application callsSetSupportedTransferSyntaxes. -
Storage Commitment (PS3.4 Annex J), as both SCU and SCP. An SCU asks a
peer to take permanent responsibility for instances it has already sent, so it
can delete its own copies:resp, err := scu.RequestStorageCommitment(ctx, &network.StorageCommitmentRequest{ TransactionUID: network.GenerateUID(), Instances: refs, }) result, err := scu.ReceiveStorageCommitmentResult(ctx)An SCP provides it by implementing
StorageCommitmentProvider, or with the
StorageCommitmentHandlerconvenience type. A handler that does not implement
it causes requests to be refused rather than silently accepted — accepting
tells the requestor it may delete its only copy.The event type is derived from the result rather than passed in, so a report
cannot claim everything succeeded while listing failures.Verified against pynetdicom, which reads the transaction UID, action type,
and every instance reference. That check runs in CI. -
commitscuCLI command, andnetwork.GenerateUID()for minting UIDs
under the UUID-derived arc (2.25, ITU-T X.667), which needs no registered
root. -
Compressed frames can be extracted.
filereaderdiscarded the Basic
Offset Table and item headers of encapsulated Pixel Data and concatenated the
fragment payloads, on the reasoning that frame boundaries would be recovered
later. They could not be — theencapspackage recovers them by parsing that
structure, soExtractEncapsulatedFramesfailed with "failed to parse basic
offset table" on every compressed file, and multi-frame images could not be
split at all.PixelDatanow holds the encapsulation exactly as it appears in
the file, matching what pydicom exposes.Verified against pydicom:
MR_small_RLE.dcmgives one 6108-byte fragment
from 6128 bytes of pixel data, andSC_rgb_rle_2frame.dcmsplits into two
664-byte fragments with a two-entry offset table — identical in both cases.This does not decode anything:
PixelArray()still cannot decompress. It is
the step that had to come first. -
RLE Lossless pixel data decodes.
Dataset.PixelArray()now decompresses
encapsulated pixel data instead of handing it to the sample parsers as though
it were raw, which failed on every compressed file with "insufficient pixel
data at frame 0, row N, col M".The RLE decoder itself was rewritten. It had ignored the 64-byte segment
header (PS3.5 §G.5) and PackBits-decoded the whole frame as one stream,
treating the header's offsets as control bytes — 8736 bytes out of
MR_small_RLE.dcmwhere 8192 is correct, and not pixel data at any offset.
Segments are now decoded separately and interleaved, since each sample is
split across one segment per byte, most significant first.Verified against pydicom in both directions:
MR_small_RLE.dcmand both
frames ofSC_rgb_rle_2frame.dcmdecode byte for byte to what pydicom reads,
and pydicom decodes a frame this library encoded back to the original pixels.
MR_small_RLE.dcmalso decodes to exactly the values of its uncompressed
twinMR_small.dcm. These comparisons run in CI. -
Datasetcarries its transfer syntax.SetTransferSyntaxUIDand
TransferSyntaxUID, populated byfilereader. Whether PixelData is raw or
encapsulated, and which codec compressed it, are properties of the transfer
syntax; the meta header is not part of the data set, so aDatasethad no way
to learn how its own pixels were encoded. -
Deflated Explicit VR Little Endian files can be read and written.
filereadernever inflated, soimage_dfl.dcmparsed to 0 elements; it
now parses to 29, matching pydicom, with its pixel data decoding correctly.Writing was equally broken in the other direction:
filewriterignored the
transfer syntax and wrote an uncompressed body, so a file declaring
1.2.840.10008.1.2.1.99could be read by nothing — including this library,
whose reader inflates on the strength of that declaration and failed with
"flate: corrupt input before offset 5". Verified: a file written this way is
read back by pydicom with matching element values and identical pixel
data. -
Patient/Study Only query/retrieve information model
(1.2.840.10008.5.1.4.1.2.3.x). Retired in the current standard but still the
only model some archives offer. Added to the SCP's default contexts and to the
SCU's Find/Move/Get fallback chain as a third rung. -
dataelem.SwapByteOrderanddataelem.IsByteOrderSensitive— one byte-order
implementation shared by the reader and the writer, so they cannot drift apart. -
compress.InflateLimitFor,MaxInflateRatio,MinInflateAllowance. -
RLE Lossless compression on send. Sending over a context that negotiated a
compressed syntax previously failed outright. Pixel data is now transcoded in
both directions as the negotiated context requires — from native pixels, or by
decoding a compressed source and re-encoding. RLE remains the only syntax this
library compresses to; every other compressed target still fails rather than
putting bytes on the wire described as something they are not. -
12-bit JPEG Extended decoding, in pure Go. The standard library handles
SOF1 frames and rejects only the precision, which is the depth the transfer
syntax exists to carry. -
UPS Watch — subscriptions and N-EVENT-REPORT. All three targets of PS3.4
CC.2.3 are supported, including the Global and Filtered Global instances, whose
subscribers are resolved when an event happens rather than expanded when the
subscription is made, so they cover steps created afterwards.
Server.ReportUPSEventdelivers events over an association the SCP opens back
to the subscriber. A subscriber that cannot be reached does not fail the
N-ACTION: the transition is already stored, and refusing it would leave the SCP
and the performer disagreeing about who owns the step. -
The DICOM JSON Model of PS3.18 Annex F —
ToDICOMJSON,FromDICOMJSON
and their string forms, with bulk data URIs. The README claimed this and the
jsonreppackage's documentation claimed to implement it;jsonrepis a
struct of twenty-five named fields and cannot represent an arbitrary data set,
andDataset.ToJSONproduces a readable rendering nothing outside this library
can consume. Both now say what they are. Verified against pydicom'sto_json
across its corpus. -
DICOMDIR reading and writing. Directory records are stored in one flat
sequence and describe a tree linked by byte offsets, so the tree is built from
the offsets rather than from record order — a conforming file may store them in
any order, which is whatDICOMDIR-reorderedexists to demonstrate. Writing
computes those offsets by laying the file out twice and re-reads its own output
before returning it, because a file with wrong offsets is worse than no file: a
reader accepts it and follows it into a tree that is not there. -
The SR content tree of PS3.3 C.17 —
ReadContentTreeand
WriteContentTree, including by-reference relationships, whose Referenced
Content Item Identifier is UL rather than the text most multi-valued attributes
use. The package could not read a content item: pydicom sees 28 in
test-SR.dcmand this saw none. -
SV, UV and OV, the 64-bit value representations DICOM added in 2018. An
unrecognized VR is not a small problem in explicit encoding, because the VR
decides the shape of the header. -
Private attributes take their VR from the shipped vendor dictionary. The
6883-attribute private dictionary was never consulted. A lookup requires the
file's own private creator, since PS3.5 7.8.1 lets a vendor claim any block
from 0x10 to 0xFF and the same attribute is at a different tag in every file. -
nibabel joins the interoperability suite, alongside pynetdicom, dcmtk and
pydicom.
Security
- A deflated file could force an allocation 50,000x its own size. The 256 MiB
decompression-bomb ceiling bounded the output but let the attacker choose the
cost: a 300 KB file produced a measured 603 MiB peak heap before being
rejected, becauseio.ReadAllgrows its buffer by doubling. The allowance is
now the smaller of the ceiling and 1000x the compressed size, with an 8 MiB
floor — DEFLATE reaches its theoretical maximum ratio on genuinely blank
medical images, so a ratio alone would reject an all-black frame. Both the
file and network paths share one implementation. - The security policy named a reporting channel that did not exist. It
forbade public issues and asked for an email that appeared nowhere in the
repository. Reports now go through GitHub private vulnerability reporting,
which has been enabled.
Changed
-
The JPEG-LS, JPEG 2000, and JPEG Lossless errors no longer give advice that
does not work. They named a C library and told the caller to rebuild with
CGO_ENABLED=1; there is no CGO implementation in this module, so following
the instruction produced the same error. The messages, and the installation
textGetExternalCompressionStatusreturns, now say plainly that no decoder
is bundled and show how to register one. An example demonstrates it. -
The README transfer syntax table separated data set support, pixel decoding,
and network transfer into distinct columns. A compressed syntax marked
"Read/Write" had implied its pixels were usable when only the data set parsed.
No compressed syntax claims pixel decoding — including RLE, whose decoder
returns 8736 bytes where 8192 is correct. -
CI runs on Node 24 actions throughout, and lints with golangci-lint v2.
Known limitations
Six, all documented in CONFORMANCE section 8. Two are gaps; four are deliberate,
and removing them would make the library worse or wrong.
- JPEG 2000 has no bundled decoder. No implementation ships one in pure
language — pydicom needs pylibjpeg or GDCM, dcmtk needs extra modules. Supply
one throughcompress.GetExternalRegistry().RegisterExternalDecoder;
examples/jpeg2000is a working decoder verified sample-for-sample against
pydicom. - RLE Lossless is the only syntax this library compresses to. Decoders exist
for JPEG Lossless, JPEG-LS and 12-bit JPEG Extended; the encoders do not. PixelArrayflattens color samples into the column dimension, so a 100x100
RGB frame is reported as 100x300. The values and their order are correct.
PixelArrayBySamplereturns the four-dimensional shape.- Samples are returned in the color space the Photometric Interpretation names,
so aYBR_FULLinstance yields YBR rather than RGB — the same as pydicom.
Converting while the attribute still says YBR would have the next reader
convert again. - Compressed transfer syntaxes are not negotiated by default, as in pynetdicom.
PassAllTransferSyntaxes()to accept them. - A C-FIND, C-GET or C-MOVE handler that returns a complete result set cannot
observe a cancellation, having finished before the first result was sent. The
streaming interfaces —CFindStreamer,CGetStreamer,CMoveStreamer—
receive a context that is canceled when a C-CANCEL naming the message arrives.
Install
go install github.com/amrshadid/go-dicom@v1.4.0Or download a binary below and place it on your PATH:
| Platform | File |
|---|---|
| Linux x86-64 | dicom-linux-amd64 |
| Linux ARM64 | dicom-linux-arm64 |
| macOS Intel | dicom-macos-amd64 |
| macOS Apple Silicon | dicom-macos-arm64 |
| Windows x86-64 | dicom-windows-amd64.exe |
Checksums are in SHA256SUMS:
sha256sum -c SHA256SUMS