Skip to content

Datasets ​

A Dataset is a collection of DataElements keyed by Tag. A FileDataset wraps one with the things a Part 10 file carries around it — the preamble, the File Meta Information, and the filename it came from:

go
type FileDataset struct {
	*Dataset
	Filename  string
	Preamble  []byte
	FileMeta  *FileMetaDataset
	Timestamp string
}

Because *Dataset is embedded, every method below works on both.

Reading ​

Entry pointSource
ReadFile(filename, opts)a path
Read(r, opts)any io.Reader
ReadBytes(data, opts)a []byte
DecodeDataset(data, ts)dataset bytes with no File Meta, transfer syntax given
DecodeDatasetEncoding(data, isImplicitVR, isLittleEndian)the same, encoding given directly

Each has a …Context variant taking a context.Context first, for cancellation and request-scoped logging: ReadFileContext, ReadContext, ReadBytesContext, DecodeDatasetContext, DecodeDatasetEncodingContext.

Prefer a seekable source — an *os.File rather than a wrapped stream — so that StopBeforePixels, DeferSize, SpecificTags and OnElement can skip large values instead of buffering them.

ReadOptions ​

go
type ReadOptions struct {
	DeferSize        uint32
	StopBeforePixels bool
	Force            bool
	SpecificTags     []Tag
	OnElement        func(RawDataElement, []PathStep) ElementAction
	Logger           *slog.Logger
	OnDiagnostic     func(Diagnostic) error
	Dictionary       Dictionary
}
  • DeferSize — values larger than this many bytes are not read up front. The element is present, and its value is loaded on first access.
  • StopBeforePixels — stop at Pixel Data. The fast path when you only want the header.
  • Force — read a file that has no DICM prefix, the way pydicom's force=True does.
  • SpecificTags — keep these tags at the top level (plus the group-length and File Meta elements needed to get there). A sequence that survives the filter is parsed whole, including every element inside its items.
  • OnElement — called once per element, after the header and before the value, at every depth. Return ElementKeep, ElementSkip or ElementStop. The hook receives a RawDataElement whose Value is nil, and the path of enclosing sequences. Group 0x0002 and Specific Character Set are never offered. On a seekable source a skip does not read the value bytes. StopBeforePixels and SpecificTags run first; the hook can only narrow what they kept.
  • Logger — see Logging.
  • OnDiagnostic — see Diagnostics.
  • Dictionary — resolve tags for this read. Nil uses Standard().

Deferred values ​

With DeferSize set, a large value is a promise rather than a buffer. Accessing it through Get triggers the load; LoadDeferred does it explicitly:

go
ds, err := godicom.ReadFile("ct.dcm", &godicom.ReadOptions{DeferSize: 1024})
if err != nil {
	log.Fatal(err)
}
// … later, when the pixels are actually needed:
if err := ds.LoadDeferred(tag.PixelData); err != nil {
	log.Fatal(err) // the file moved, or changed under us
}

FileDataset.Timestamp is the source file's modification time, and a deferred load checks it — a file rewritten behind your back does not silently yield the wrong bytes. If a deferred load fails after the read returned, it is reported through ReadOptions.OnDiagnostic, which is why that hook must be safe to call from wherever the dataset is used.

Reading elements ​

Typed getters return the value and whether it was there:

go
name, ok := ds.GetString(tag.PatientName)
rows, ok := ds.GetInt(tag.Rows)
thickness, ok := ds.GetFloat(tag.SliceThickness)
spacing, ok := ds.GetFloats(tag.PixelSpacing)
raw, ok := ds.GetBytes(tag.PixelData)
seq, ok := ds.GetSequence(tag.ReferencedImageSequence)

For VRs with their own semantics there are VR-typed getters that preserve them rather than flattening to a Go primitive — GetDA, GetTM, GetDT, GetIS, GetDS, GetPN:

go
pn, ok := ds.GetPN(tag.PatientName) // PersonName: family, given, middle, prefix, suffix
da, ok := ds.GetDA(tag.StudyDate)

StringValue, IntValue, FloatValue, BytesValue and SequenceValue are one-line aliases for the corresponding Get* — same behaviour, pick whichever reads better where you are.

For the element itself, rather than its value: Get, GetDataElement, ElementByKeyword, Has, Len.

Writing elements ​

The setters take the VR from the data dictionary and reject a value the VR cannot hold, at the call site:

go
ds.SetString(tag.PatientID, "12345678")
ds.SetStrings(tag.ImageType, "DERIVED", "SECONDARY")
ds.SetInt(tag.Rows, 512)
ds.SetInts(tag.AcquisitionMatrix, 0, 256, 256, 0)
ds.SetFloat(tag.SliceThickness, 1.5)
ds.SetFloats(tag.PixelSpacing, 0.5, 0.5)
ds.SetBytes(tag.PixelData, raw)
ds.SetSequence(tag.ReferencedImageSequence, seq)

They all return an error — check it. SetFloat(tag.EchoNumbers, 1.5) fails, because EchoNumbers is an IS and an IS holds no floating-point values.

There are VR-typed setters to match the VR-typed getters, for when you have the VR's own type in hand rather than a Go primitive — SetDA, SetTM, SetDT, SetIS, SetDS, SetPN:

go
ds.SetPN(tag.PatientName, pn)
ds.SetDA(tag.StudyDate, da)

Two escape hatches, in increasing order of how much you are taking on yourself:

go
// VR from the dictionary, value type NOT checked against it.
ds.SetValue(tag.PatientID, "12345678")

// VR supplied by you. For private tags and anything else the dictionary
// does not know. Note it returns nothing: there is no dictionary left to
// disagree with you.
ds.Set(godicom.NewDataElement(tag.New(0x0009, 0x0010), godicom.VRLO, "ACME"))

Values a setter cannot judge — in range for their Go type but not for their VR — are caught on the way out instead, by WriteOptions.OnDiagnostic.

Removing: Delete, Pop, Clear, RemovePrivateTags.

Sequences ​

A Sequence is a list of *Dataset items:

go
item := godicom.NewDataset()
if err := item.SetString(tag.CodeValue, "T-A0100"); err != nil {
	log.Fatal(err)
}
if err := item.SetString(tag.CodingSchemeDesignator, "SRT"); err != nil {
	log.Fatal(err)
}

seq := godicom.NewSequence([]*godicom.Dataset{item})
seq.Append(anotherItem)

if err := ds.SetSequence(tag.AnatomicRegionSequence, seq); err != nil {
	log.Fatal(err)
}

Reading back:

go
if seq, ok := ds.GetSequence(tag.AnatomicRegionSequence); ok {
	for _, item := range seq.Items() {
		code, _ := item.GetString(tag.CodeValue)
		fmt.Println(code)
	}
}

Len, Get(i), IsEmpty and Items are the whole surface.

Private tags ​

A vendor reserves whichever block of a private group is free when it writes (PS3.5 §7.8.1), so the element a vendor documents as offset 0x01 is (0041,1101) in one file and (0041,1001) in the next. Ask for the block by creator name and the offsets are yours to use:

go
block, ok := ds.PrivateBlock(0x0041, "ACME 3.2")
if !ok {
	// No block for that creator. The ordinary case for another vendor's file,
	// which is why this returns ok rather than a nil pointer.
}
elem, _ := block.Get(0x01)
fmt.Println(block.GetTag(0x01), elem.Value)

Writing needs a block reserved first — a private element whose group holds no Private Creator naming its block is one no reader can attribute to anyone. NewPrivateBlock finds a free block, writes the Private Creator element and hands back the block:

go
block, err := ds.NewPrivateBlock(0x0041, "ACME 3.2")
if err != nil {
	return err
}
block.Set(0x01, godicom.VRUS, 4095)

GetTag, Get, Set and Delete take the offset as a uint8, which is what a block offset is; ds.PrivateCreators(group) lists the creators a group names, and ds.RemovePrivateTags() drops every private element.

Walking a dataset ​

MethodOrder and depth
Iter()top-level elements, tag order
IterAll()every element, recursing into sequences (pydicom's iterall)
Walk(fn, recursive)callback per element in tag order (pydicom's walk)
SortedTags()the tags, sorted
Elements()the underlying map[Tag]*DataElement
GroupDataset(group)a new Dataset of one group only

Whole-dataset operations: Clone, Equal, Update, String, Top, FormattedLines.

Tags and UIDs ​

Tags come from the tag package as constants, and there are four ways to get one at runtime:

go
t := tag.New(0x0010, 0x0010)             // group and element
t, err := tag.Parse("(0010,0010)")       // also "0010,0010" and "00100010"
t = tag.MustParse("(0010,0010)")         // panics instead, for package vars
t, ok := tag.ByKeyword("PatientName")    // from the dictionary keyword

tag.FromKeyword is the error-returning form of ByKeyword, and tag.Keyword goes the other way. At the root, godicom.ParseTag accepts any of the string forms or a group/element pair, which is what the CLI's -t flag uses; godicom.TagFromKeyword is the keyword lookup.

Tag also answers the questions you would otherwise write yourself: Group, Element, IsPrivate, IsPrivateCreator, PrivateCreator, and JSONKey for the DICOM JSON Model.

For UIDs, uid.UID carries the dictionary with it, so branching on a transfer syntax needs no table of your own. FileDataset.TransferSyntaxUID reads it out of the File Meta for you:

go
ts, ok := fd.TransferSyntaxUID()
if ok && ts.IsEncapsulated() {
	// compressed frames, in items
}
fmt.Println(ts.Name(), ts.IsCompressed(), ts.IsDeflated(),
	ts.IsImplicitVR(), ts.IsLittleEndian())

IsTransferSyntax, IsRetired, IsPrivate, IsValid, Keyword, Type and ExtraInfo round it out. uid.Lookup takes a UID and returns what the registry records about it as a uid.Info; uid.LookupKeyword goes the other way, from keyword to UID.

Generating one:

go
u, err := uid.GenerateUID()                            // the godicom root
u, err = uid.GenerateUID(uid.WithPrefix("1.2.3.4."))   // your own org root
u, err = uid.GenerateUID(uid.WithUUIDPrefix())         // 2.25.<uuid>, PS3.5 B.2

uid.MustGenerateUID panics rather than returning an error, and uid.WithEntropy mixes in your own strings for reproducible generation.

Writing ​

Entry pointProduces
WriteFile(filename, ds *Dataset, opts)a Part 10 file, File Meta derived from the dataset
FileDataset.SaveAs(filename, opts) / Dataset.SaveAs(filename, opts)the same, method form
Write(w, fd *FileDataset, opts) / FileDataset.Write(w, opts)Part 10 bytes to an io.Writer
EncodeFile(fd *FileDataset, opts) / FileDataset.EncodeFile(opts)Part 10 bytes in memory
WriteDataset(w, ds *Dataset, ts) / EncodeDataset(ds, ts)dataset bytes, no preamble or File Meta
Dataset.Encode(ts) / Dataset.EncodeEncoding(implicit, littleEndian)the same, method form

The *FileDataset entry points are the ones that carry a preamble and an existing File Meta through; WriteFile takes a bare *Dataset and derives the File Meta, which is why it pairs with EnforceFileFormat.

WriteOptions ​

go
type WriteOptions struct {
	ImplicitVR        *bool
	LittleEndian      *bool
	EnforceFileFormat bool
	Logger            *slog.Logger
	OnDiagnostic      func(Diagnostic) error
}

ImplicitVR and LittleEndian are pointers so that "leave it as it was" is distinguishable from "make it explicit little endian". Left nil, a dataset read from a file is written back in the encoding it arrived in.

EnforceFileFormat requires a conformant Part 10 result and fills the File Meta in from the dataset. It is what you want when you are creating a file rather than round-tripping one — and it will tell you what is missing:

required File Meta Information elements are missing or empty:
[(0002,0002) MediaStorageSOPClassUID (0002,0003) MediaStorageSOPInstanceUID]

which means the dataset needs SOPClassUID and SOPInstanceUID set.

WARNING

WriteDataset and EncodeDataset take no *WriteOptions, so they can carry no diagnostic hook. If you want write diagnostics, use a path that takes options.

Dataset bytes with no File Meta ​

What a DIMSE message or a multipart DICOMweb payload carries:

go
data, err := ds.Encode(uid.ExplicitVRLittleEndian)
parsed, err := godicom.DecodeDataset(data, uid.ExplicitVRLittleEndian)

Encoding of a re-read dataset ​

SetOriginalEncoding and SetWriteEncoding set the encoding explicitly; IsOriginalEncoding reports whether a dataset would be written back exactly as it was read. Values that have not been touched are written from the bytes they were read as, which is both faster and lossless — and the reason such values are never offered to the write diagnostic hook.

DICOMDIR ​

OpenFileSet reads a Media Storage Directory and walks the directory records in the order their offsets link them: Patient, Study, Series, then the instance, or whatever record types the file actually uses. Each instance's path is the directory that contains the DICOMDIR, plus its Referenced File ID.

go
fs, err := godicom.OpenFileSet("DICOMDIR", nil)
if err != nil {
	log.Fatal(err)
}
for _, inst := range fs.Instances() {
	fmt.Println(inst.Path())
}

matches := fs.Find(map[godicom.Tag]string{
	tag.PatientID: "77654033",
})
if len(matches) == 0 {
	log.Fatal("no match")
}
ds, err := matches[0].Load()

tag is github.com/godicom-dev/godicom/tag. Find requires every tag in the query to match. An empty query returns every instance. SOPInstanceUID, SOPClassUID and TransferSyntaxUID match the referenced-file elements, and each record is searched from the instance up through its ancestors, so a Patient ID stored on the patient record still selects that patient's instances.

An instance with an empty File ID, or whose file is missing, is left out and a warning is logged. A File ID that resolves outside the DICOMDIR's directory is an error. Records the offset chain never reaches are left out; set FileSetOptions{RaiseOrphans: true} to make that an error.

LoadFileSet is the same read, starting from a dataset you already have. Its Filename must be the path of the DICOMDIR file.

Writing a DICOMDIR, and adding or removing records, is not included.

Released under the MIT License.