数据集
Dataset 是一组按 Tag 索引的 DataElement。FileDataset 在它外面包上 Part 10 文件携带的那些东西 —— preamble、File Meta Information,以及它来自哪个文件:
type FileDataset struct {
*Dataset
Filename string
Preamble []byte
FileMeta *FileMetaDataset
Timestamp string
}因为 *Dataset 是嵌入的,下面每个方法在两者上都能用。
读取
| 入口 | 来源 |
|---|---|
ReadFile(filename, opts) | 一个路径 |
Read(r, opts) | 任意 io.Reader |
ReadBytes(data, opts) | 一个 []byte |
DecodeDataset(data, ts) | 没有 File Meta 的数据集字节,传输语法由你给出 |
DecodeDatasetEncoding(data, isImplicitVR, isLittleEndian) | 同上,但直接给出编码 |
每个都有一个 …Context 变体,首参接收 context.Context,用于取消和请求域内的日志:ReadFileContext、ReadContext、ReadBytesContext、DecodeDatasetContext、DecodeDatasetEncodingContext。
尽量给可 seek 的源 —— 用 *os.File 而不是包了一层的流 —— 这样 StopBeforePixels、DeferSize、SpecificTags 和 OnElement 才能跳过大值,而不是把它们缓冲下来。
ReadOptions
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—— 超过这个字节数的值不预先读入。元素存在,值在首次访问时加载。StopBeforePixels—— 到 Pixel Data 停下。只要头部信息时的快路径。Force—— 读取没有 DICM 前缀的文件,等价于 pydicom 的force=True。SpecificTags—— 只保留数据集顶层的这些标签(外加为了到达它们所必需的组长度和 File Meta 元素)。通过过滤的序列会整段解析,item 里的元素不会被丢掉。OnElement—— 每个元素调用一次,在读完头部、读值之前,每一层都会调用。返回ElementKeep、ElementSkip或ElementStop。钩子拿到的RawDataElement的Value是 nil,另有一条外层序列的路径。组0x0002和 Specific Character Set 不会交给钩子。在可 seek 的源上,跳过不会去读值的字节。StopBeforePixels和SpecificTags先执行;钩子只能在它们留下的范围内再收窄。Logger—— 见日志。OnDiagnostic—— 见诊断。Dictionary—— 为这一次读取解析标签。nil 时用Standard()。
延迟加载的值
设了 DeferSize 之后,一个大值是一个承诺而不是一段缓冲。通过 Get 访问会触发加载;LoadDeferred 则是显式触发:
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 是源文件的修改时间,延迟加载会核对它 —— 一个在你背后被重写过的文件不会悄悄给出错误的字节。如果延迟加载在读取返回之后才失败,会通过 ReadOptions.OnDiagnostic 上报,这也是那个钩子必须能从数据集被使用的任何地方安全调用的原因。
读取元素
带类型的 getter 返回值以及它是否存在:
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)对于自带语义的 VR,有一组 VR 类型化的 getter 保留这些语义,而不是压平成 Go 基本类型 —— GetDA、GetTM、GetDT、GetIS、GetDS、GetPN:
pn, ok := ds.GetPN(tag.PatientName) // PersonName: family, given, middle, prefix, suffix
da, ok := ds.GetDA(tag.StudyDate)StringValue、IntValue、FloatValue、BytesValue 和 SequenceValue 是对应 Get* 的一行别名 —— 行为一样,在哪处读起来更顺就用哪个。
要拿元素本身而不是它的值:Get、GetDataElement、ElementByKeyword、Has、Len。
写入元素
setter 从数据字典取 VR,并在调用处拒绝该 VR 装不下的值:
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)它们全都返回 error —— 请检查。SetFloat(tag.EchoNumbers, 1.5) 会失败,因为 EchoNumbers 是 IS,而 IS 装不下浮点值。
与 VR 类型化的 getter 相对,也有一组 VR 类型化的 setter,供你手上已经是 VR 自己的类型而不是 Go 基本类型时使用 —— SetDA、SetTM、SetDT、SetIS、SetDS、SetPN:
ds.SetPN(tag.PatientName, pn)
ds.SetDA(tag.StudyDate, da)两个逃逸出口,按你要自己担多少责任递增排列:
// 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"))setter 判断不了的值 —— 对它的 Go 类型来说在范围内、对它的 VR 来说不在 —— 会在写出时被抓住,交给 WriteOptions.OnDiagnostic。
删除:Delete、Pop、Clear、RemovePrivateTags。
序列
Sequence 是一串 *Dataset item:
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)
}读回来:
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 和 Items 就是对外能用的全部方法。
私有标签
厂商写文件时占用私有组里当时空闲的那个块(PS3.5 §7.8.1),所以厂商文档里写作偏移 0x01 的那个元素,在一个文件里是 (0041,1101),在下一个文件里就是 (0041,1001)。按创建者名字要这个块,偏移就归你用了:
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)写入前得先占一个块——一个私有元素,如果它所在的组里没有 Private Creator 说明它属于哪个块,那么任何读取方都无法把它归属给任何人。NewPrivateBlock 找一个空闲块、写好 Private Creator 元素,再把块交回来:
block, err := ds.NewPrivateBlock(0x0041, "ACME 3.2")
if err != nil {
return err
}
block.Set(0x01, godicom.VRUS, 4095)GetTag、Get、Set 和 Delete 的偏移是 uint8,因为块偏移本来就只是这么大;ds.PrivateCreators(group) 列出一个组里有哪些创建者,ds.RemovePrivateTags() 删掉所有私有元素。
遍历数据集
| 方法 | 顺序与深度 |
|---|---|
Iter() | 顶层元素,按标签顺序 |
IterAll() | 每一个元素,递归进序列(pydicom 的 iterall) |
Walk(fn, recursive) | 按标签顺序对每个元素回调(pydicom 的 walk) |
SortedTags() | 排好序的标签 |
Elements() | 底层的 map[Tag]*DataElement |
GroupDataset(group) | 只含某一个组的新 Dataset |
整数据集级的操作:Clone、Equal、Update、String、Top、FormattedLines。
标签与 UID
标签以常量形式来自 tag 包,运行时取一个标签有四种方式:
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 keywordtag.FromKeyword 是 ByKeyword 的返回 error 形式,tag.Keyword 是反方向。在根包里,godicom.ParseTag 接受上面任意一种字符串形式或一对 group/element,命令行的 -t 用的就是它;godicom.TagFromKeyword 是关键字查询。
Tag 还回答了那些你本来得自己写的问题:Group、Element、IsPrivate、IsPrivateCreator、PrivateCreator,以及给 DICOM JSON Model 用的 JSONKey。
UID 方面,uid.UID 自带字典,所以按传输语法分支不需要你自备一张表。FileDataset.TransferSyntaxUID 帮你从 File Meta 里读出来:
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 和 ExtraInfo 把它补齐。uid.Lookup 收一个 UID,返回注册表里关于它的记录(一个 uid.Info);反方向、从关键字到 UID 的是 uid.LookupKeyword。
生成一个:
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.2uid.MustGenerateUID 用 panic 代替返回 error,uid.WithEntropy 混入你自己的字符串以便可复现地生成。
写入
| 入口 | 产出 |
|---|---|
WriteFile(filename, ds *Dataset, opts) | 一个 Part 10 文件,File Meta 从数据集推导 |
FileDataset.SaveAs(filename, opts) / Dataset.SaveAs(filename, opts) | 同上,方法形式 |
Write(w, fd *FileDataset, opts) / FileDataset.Write(w, opts) | Part 10 字节写到 io.Writer |
EncodeFile(fd *FileDataset, opts) / FileDataset.EncodeFile(opts) | 内存中的 Part 10 字节 |
WriteDataset(w, ds *Dataset, ts) / EncodeDataset(ds, ts) | 数据集字节,没有 preamble 和 File Meta |
Dataset.Encode(ts) / Dataset.EncodeEncoding(implicit, littleEndian) | 同上,方法形式 |
带 *FileDataset 的入口会把 preamble 和已有的 File Meta 一路带过去;WriteFile 收的是裸 *Dataset 并自行推导 File Meta,这也是它和 EnforceFileFormat 配对的原因。
WriteOptions
type WriteOptions struct {
ImplicitVR *bool
LittleEndian *bool
EnforceFileFormat bool
Logger *slog.Logger
OnDiagnostic func(Diagnostic) error
}ImplicitVR 和 LittleEndian 是指针,好让「保持原样」能和「改成 explicit little endian」区分开。留 nil 时,从文件读来的数据集会按它进来时的编码写回去。
EnforceFileFormat 要求结果是合规的 Part 10,并从数据集填好 File Meta。新建文件时用它,回写一份已经存在的文件时不必开。缺了什么,它会直接告诉你:
required File Meta Information elements are missing or empty:
[(0002,0002) MediaStorageSOPClassUID (0002,0003) MediaStorageSOPInstanceUID]意思是这个数据集需要设上 SOPClassUID 和 SOPInstanceUID。
WARNING
WriteDataset 和 EncodeDataset 不接收 *WriteOptions,所以它们带不了诊断钩子。想要写入诊断,就用接收 options 的那条路径。
没有 File Meta 的数据集字节
DIMSE 消息或 DICOMweb multipart 载荷里装的就是这个:
data, err := ds.Encode(uid.ExplicitVRLittleEndian)
parsed, err := godicom.DecodeDataset(data, uid.ExplicitVRLittleEndian)重新读入的数据集的编码
SetOriginalEncoding 和 SetWriteEncoding 显式设定编码;IsOriginalEncoding 报告一个数据集是否会被原样写回。没有被改动过的值会按它们被读入时的字节写出,这样既更快也无损 —— 也正是这类值永远不会被送到写入诊断钩子的原因。
DICOMDIR
OpenFileSet 读取一份 Media Storage Directory,按记录之间的偏移把目录记录串成树:Patient、Study、Series,然后是实例。文件里实际用了别的记录类型,也按那个顺序走。每个实例的路径是 DICOMDIR 所在目录,加上它的 Referenced File ID。
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 是 github.com/godicom-dev/godicom/tag。Find 要求查询里的每个标签都对上。查询为空时返回全部实例。SOPInstanceUID、SOPClassUID 和 TransferSyntaxUID 对的是文件里的 Referenced SOP Instance UID、Referenced SOP Class UID 和 Referenced Transfer Syntax UID。查找从实例自己的记录开始,再往上找祖先,所以写在 Patient 记录上的 Patient ID 也能选出这个病人的实例。
File ID 为空,或者指向的文件不存在,这条实例会被丢掉,并记一条警告。File ID 解析到 DICOMDIR 所在目录之外会报错。偏移链走不到的记录默认不进这棵树;设上 FileSetOptions{RaiseOrphans: true},这种情况改为报错。
LoadFileSet 做的是同一次读取,起点是你已经解析好的数据集。它的 Filename 必须是这份 DICOMDIR 文件的路径。
写 DICOMDIR、增删记录,目前没有。