Skip to content

v1.4.0

Choose a tag to compare

@amrshadid amrshadid released this 31 Jul 22:40
· 32 commits to main since this release
5583a7d

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. NumberOfFrames is IS, and
    PS3.5 §6.2 pads a value to an even length with a trailing space, so "2 "
    failed strconv.Atoi and 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 in filereader, ignoring the byte order the reader had already been
      given. MR_small_bigendian.dcm parsed 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, so Dataset needs 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, and filewriter
      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.dcm now 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.

  • C-CANCEL aborted the association. SCU.Cancel sent 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 file input to the coverage upload was silently ignored in CI, and
    govulncheck was 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 through DecodePersonName and
    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 to ISO_IR 192 so 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 10 becomes 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-nooffset lost 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.

  • fileset searched nothing and counted wrongly. FindByModality,
    FindByPatient, FindByStudyInstanceUID and FindBySeriesInstanceUID ignored
    their argument and returned every record; GetStatistics reported the file
    count as the patient, study and series counts; ScanDirectory and AddFile
    never parsed the files they listed, so nothing above them had anything to work
    with; and GenerateDICOMDIR returned 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, so sizeof_hdr was 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 calls SetSupportedTransferSyntaxes.

  • 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
    StorageCommitmentHandler convenience 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.

  • commitscu CLI command, and network.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. filereader discarded 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 — the encaps package recovers them by parsing that
    structure, so ExtractEncapsulatedFrames failed with "failed to parse basic
    offset table" on every compressed file, and multi-frame images could not be
    split at all. PixelData now holds the encapsulation exactly as it appears in
    the file, matching what pydicom exposes.

    Verified against pydicom: MR_small_RLE.dcm gives one 6108-byte fragment
    from 6128 bytes of pixel data, and SC_rgb_rle_2frame.dcm splits 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.dcm where 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.dcm and both
    frames of SC_rgb_rle_2frame.dcm decode byte for byte to what pydicom reads,
    and pydicom decodes a frame this library encoded back to the original pixels.
    MR_small_RLE.dcm also decodes to exactly the values of its uncompressed
    twin MR_small.dcm. These comparisons run in CI.

  • Dataset carries its transfer syntax. SetTransferSyntaxUID and
    TransferSyntaxUID, populated by filereader. 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 a Dataset had no way
    to learn how its own pixels were encoded.

  • Deflated Explicit VR Little Endian files can be read and written.
    filereader never inflated, so image_dfl.dcm parsed to 0 elements; it
    now parses to 29, matching pydicom, with its pixel data decoding correctly.

    Writing was equally broken in the other direction: filewriter ignored the
    transfer syntax and wrote an uncompressed body, so a file declaring
    1.2.840.10008.1.2.1.99 could 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.SwapByteOrder and dataelem.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.ReportUPSEvent delivers 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 FToDICOMJSON, FromDICOMJSON
    and their string forms, with bulk data URIs. The README claimed this and the
    jsonrep package's documentation claimed to implement it; jsonrep is a
    struct of twenty-five named fields and cannot represent an arbitrary data set,
    and Dataset.ToJSON produces a readable rendering nothing outside this library
    can consume. Both now say what they are. Verified against pydicom's to_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 what DICOMDIR-reordered exists 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.17ReadContentTree and
    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.dcm and 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, because io.ReadAll grows 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
    text GetExternalCompressionStatus returns, 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 through compress.GetExternalRegistry().RegisterExternalDecoder;
    examples/jpeg2000 is 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.
  • PixelArray flattens color samples into the column dimension, so a 100x100
    RGB frame is reported as 100x300. The values and their order are correct.
    PixelArrayBySample returns the four-dimensional shape.
  • Samples are returned in the color space the Photometric Interpretation names,
    so a YBR_FULL instance 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.
    Pass AllTransferSyntaxes() 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.0

Or 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

Full changelog