A Go implementation of the DICOM networking protocol and DICOMweb (PS3.18) transactions.
DICOM is the international standard for medical images and related information. It defines the formats and communication protocols for media exchange in radiology, cardiology, radiotherapy and other medical domains.
gonetdicom implements the DICOM networking protocol in Go. Working with godicom, it allows the easy creation of DICOM Service Class Users (SCUs) and Service Class Providers (SCPs), plus a DICOMweb user agent and origin-server MVP.
gonetdicom's main association helpers live in package
ae:
- Start as an SCP with
ae.Serve/ae.ListenAndServeTLSafter configuring accepted presentation contexts and handlers (OnCStore,OnCFind, …) - Act as an SCU with
ae.Dial, which returns an*ae.Associationyou use to send DIMSE-C and DIMSE-N messages (CEcho,CStore,CFind,CMove,CGet,NAction, …)
Dataset and pixel I/O come from godicom; gonetdicom focuses on Upper Layer PDUs, DIMSE command sets, association negotiation, and HTTP DICOMweb.
gonetdicom
└── github.com/godicom-dev/godicom
Behaviour for DIMSE is primarily aligned with pynetdicom (git submodule fixtures). DICOMweb follows DICOM PS3.18.
- pkg.go.dev API reference
- CHANGELOG
- TODO — deferred items and known gaps
go get github.com/godicom-dev/gonetdicom@latestClone with the optional reference submodule (DIMSE fixtures):
git clone --recurse-submodules https://github.com/godicom-dev/gonetdicom.gitOnce associated, the following DIMSE-C and DIMSE-N services are available on
*ae.Association:
| Service | Method |
|---|---|
| C-ECHO | CEcho |
| C-STORE | CStore |
| C-FIND | CFind |
| C-MOVE | CMove |
| C-GET | CGet |
| C-CANCEL | CCancel |
| N-EVENT-REPORT | NEventReport |
| N-GET | NGet |
| N-SET | NSet |
| N-ACTION | NAction |
| N-CREATE | NCreate |
| N-DELETE | NDelete |
ae.ServerConfig handlers: OnCStore, OnCFind, OnCMove, OnCGet,
OnNAction, OnNEventReport, OnNGet, OnNSet, OnNCreate, OnNDelete,
plus optional OnUserIdentity, MoveDestinations, role selection, and TLS.
Verification SCU (C-ECHO)
package main
import (
"context"
"log"
"time"
"github.com/godicom-dev/gonetdicom/ae"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
assoc, err := ae.Dial(ctx, ae.Config{AETitle: "MYSCU"}, "pacs.example:11112", "ANY-SCP")
if err != nil {
log.Fatal(err)
}
defer assoc.Abort()
if err := assoc.CEcho(ctx); err != nil {
log.Fatal(err)
}
if err := assoc.Release(ctx); err != nil {
log.Fatal(err)
}
}Storage SCU (C-STORE)
Propose a storage presentation context, then send a godicom Dataset (or pre-encoded bytes):
cfg := ae.Config{
AETitle: "STORESCU",
PresentationContexts: []ae.PresentationContext{{
ID: 1,
AbstractSyntax: string(uid.SecondaryCaptureImageStorage),
TransferSyntaxes: ae.UIDStrings(uid.ImplicitVRLittleEndian),
}},
}
assoc, err := ae.Dial(ctx, cfg, "pacs.example:11112", "ANY-SCP")
// ...
res, err := assoc.CStore(ctx, ae.StoreRequest{
AffectedSOPClassUID: string(uid.SecondaryCaptureImageStorage),
AffectedSOPInstanceUID: "1.2.3.4.5", // optional: Data.SOPInstanceUID or ae.NewInstanceUID()
Data: ds,
})SOP Classes and transfer syntaxes are named by godicom's
uid package, so
nothing here needs a UID pasted in; ae.UIDStrings converts a list of them to
the []string these fields hold.
CStore does not modify the Data you hand it. With no
AffectedSOPInstanceUID and no SOPInstanceUID in the dataset it generates a
UID and encodes a copy carrying it, so reusing one Dataset across a series
gives every instance its own identity; supplying the UID skips the copy.
Leaving ID unset lets Dial assign the free odd Presentation-context-IDs,
which is usually what you want; an explicit ID is kept as given. Since IDs are
odd values in 1..255, one association carries at most
ae.MaxPresentationContexts (128) of them — proposing more, an even ID, or the
same ID twice fails with ae.ErrPresentationContexts rather than putting an
ambiguous A-ASSOCIATE-RQ on the wire. To offer many storage SOP classes at once,
negotiate in batches or narrow the list.
Query / Retrieve SCU (C-FIND / C-MOVE / C-GET)
matches, err := assoc.CFind(ctx, ae.FindRequest{
QueryModel: ae.PatientRootQueryRetrieveInformationModelFind,
IdentifierData: query,
})
matches, err = assoc.CMove(ctx, ae.MoveRequest{
QueryModel: ae.PatientRootQueryRetrieveInformationModelMove,
MoveDestination: "STORESCP",
IdentifierData: query,
})
matches, err = assoc.CGet(ctx, ae.GetRequest{
QueryModel: ae.PatientRootQueryRetrieveInformationModelGet,
IdentifierData: query,
OnCStore: func(_ context.Context, req ae.StoreRequest) uint16 {
_ = req.Data // decoded Dataset
return status.Success
},
})For C-GET against real PACS, also propose SCP/SCU Role Selection so the SCU can receive C-STORE:
cfg := ae.Config{
AETitle: "GETSCU",
PresentationContexts: []ae.PresentationContext{ /* Get model + storage SOP Class */ },
RoleSelections: []pdu.RoleSelection{
ae.BuildRole(string(uid.CTImageStorage), false, true), // requestor as SCP
},
}Cancel an outstanding FIND / MOVE / GET with assoc.CCancel(ctx, msgID).
An *ae.Association carries one DIMSE operation at a time: each method sends its
request and then reads responses until the final one, so two running at once
interleave PDUs on the same connection. Dial one association per worker for
parallel work. CCancel, Abort and Close exist to reach an operation that is
already blocked, so those three are safe to call from another goroutine.
Storage SCP (C-STORE)
Serve blocks until ctx is cancelled. Do not reuse a short WithTimeout
from the C-ECHO snippet — that would shut the SCP down after a few seconds.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
ln, err := net.Listen("tcp", ":11112")
if err != nil {
log.Fatal(err)
}
err = ae.Serve(ctx, ln, ae.ServerConfig{
AETitle: "STORESCP",
AcceptedAbstractSyntaxes: ae.AllStorageSOPClasses,
OnCStore: func(_ context.Context, req ae.StoreRequest) uint16 {
if req.Data == nil || req.FileMeta == nil {
return status.ProcessingFailure
}
fd := &godicom.FileDataset{Dataset: req.Data, FileMeta: req.FileMeta}
if err := fd.SaveAs(req.AffectedSOPInstanceUID+".dcm", &godicom.WriteOptions{EnforceFileFormat: true}); err != nil {
return status.ProcessingFailure
}
return status.Success
},
})ae.AllStorageSOPClasses is the whole Storage Service Class (pynetdicom's
_STORAGE_CLASSES), built from the godicom uid constants so each entry is
named in code rather than in a comment; a test checks the set against the
pynetdicom submodule. For a narrower SCP, name the classes you accept:
ae.UIDStrings(uid.CTImageStorage, uid.MRImageStorage).
AcceptedAbstractSyntaxes may include "*" to accept any peer-proposed abstract
syntax. Named DIMSE status constants live in package
status.
The Called AE Title is checked: a requestor asking for anything other than
AETitle (or an entry in AlternativeAETitles) gets an A-ASSOCIATE-RJ with
called-AE-title-not-recognized. Set AllowAnyCalledAETitle: true for an SCP
that deliberately answers to any name. A requestor that does not announce
protocol version 1 is likewise rejected.
Three ServerConfig fields bound what a peer can cost the SCP:
HandshakeTimeout (default 30s) limits how long a connection may sit without
completing negotiation, IdleTimeout ends an association whose peer goes silent
— it is refreshed per read and write, so it bounds silence rather than total
duration — and MaxConcurrentAssociations caps associations handled at once,
answering anything above the cap with a transient local-limit-exceeded
A-ASSOCIATE-RJ. IdleTimeout and MaxConcurrentAssociations are unlimited when
unset; pass a negative HandshakeTimeout to opt out of that one deliberately.
Move Destination SCP (C-MOVE)
_ = ae.Serve(ctx, moveLn, ae.ServerConfig{
AETitle: "MOVESCP",
AcceptedAbstractSyntaxes: []string{
ae.PatientRootQueryRetrieveInformationModelMove,
},
MoveDestinations: map[string]ae.MoveDestination{
"STORESCP": {Addr: "127.0.0.1:11112", MaxAssociations: 4},
},
OnCMove: func(_ context.Context, req ae.MoveRequest) ae.MovePlan {
return ae.MovePlan{Stores: []ae.StoreRequest{{ /* ... */ }}}
},
})Storage Commitment & DIMSE-N
res, err := assoc.NAction(ctx, ae.ActionRequest{
RequestedSOPClassUID: ae.StorageCommitmentPushModelSOPClass,
RequestedSOPInstanceUID: ae.StorageCommitmentPushModelSOPInstance,
ActionTypeID: dimse.StorageCommitmentActionTypeRequest,
ActionInformationData: info,
OnNEventReport: func(_ context.Context, req ae.EventReportRequest) uint16 {
return status.Success
},
})Async N-EVENT-REPORT on a new association is available via
EventReportRequest.AsyncDestination.
User Identity Negotiation
assoc, err := ae.Dial(ctx, ae.Config{
AETitle: "IDSCU",
UserIdentity: ae.UsernamePasscodeIdentity("alice", "secret", false),
}, addr, "IDSCP")
_ = ae.Serve(ctx, ln, ae.ServerConfig{
AETitle: "IDSCP",
OnUserIdentity: func(req pdu.UserIdentityRQ) (bool, []byte) {
return string(req.PrimaryField) == "alice", nil
},
})Nil OnUserIdentity accepts the association and omits any AC response item.
client := &dicomweb.Client{BaseURL: "https://pacs.example/dicom-web"}
_, err := client.StoreFiles(ctx, "", []*godicom.FileDataset{fd})
raw, err := client.RetrieveInstance(ctx, studyUID, seriesUID, sopUID)
parts, err := client.RetrieveSeries(ctx, studyUID, seriesUID)
meta, err := client.RetrieveInstanceMetadata(ctx, studyUID, seriesUID, sopUID)
mt, img, err := client.RetrieveRenderedInstance(ctx, studyUID, seriesUID, sopUID, dicomweb.RenderOptions{
MediaType: dicomweb.MediaTypeJPEG,
Quality: 90,
})
bulk, err := client.RetrieveBulkData(ctx, studyUID, seriesUID, sopUID)
matches, err := client.SearchStudies(ctx, url.Values{"PatientID": {"P001"}})UIDs are checked before a request is built: a study, series, or instance UID
carrying a /, a .., or a % names a resource other than the one asked for, so
it fails with dicomweb.ErrInvalidPath instead of going out.
Both sides bound how much one body may buffer, since instances are held whole in
memory. Client.MaxResponseBytes (or dicomweb.WithMaxResponseBytes) defaults to
dicomweb.DefaultMaxResponseBytes (1 GiB) and fails with dicomweb.ErrTooLarge
rather than returning a truncated study; a negative value opts out.
Origin-server MVP for tests and demos:
store := dicomweb.NewMemoryStore()
http.ListenAndServe(":8080", dicomweb.Handler(store, "/dicom-web",
dicomweb.WithMaxRequestBytes(64<<20)))A STOW-RS body is buffered whole before it reaches the Store, so Handler
bounds it at dicomweb.DefaultMaxRequestBytes (256 MiB) unless
WithMaxRequestBytes says otherwise, answering anything larger with 413. Error
responses carry only a status and a fixed reason — the cause comes from your
Store, from decoding stored bytes, or from the render path, and goes to the
request context's logger (gonetdicom.WithLogger) instead of to the requestor.
gonetdicom uses Go's log/slog. By default it is silent (DiscardHandler),
similar in spirit to leaving pynetdicom's debug_logger unset.
import (
"context"
"crypto/tls"
"log/slog"
"os"
"time"
"github.com/godicom-dev/gonetdicom"
"github.com/godicom-dev/gonetdicom/ae"
"github.com/godicom-dev/gonetdicom/dicomweb"
)
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
logger := slog.New(h)
assoc, err := ae.Dial(ctx, ae.Config{
AETitle: "MYSCU",
IdleTimeout: 30 * time.Second,
TLS: &tls.Config{ServerName: "pacs.example", MinVersion: tls.VersionTLS12},
Logger: logger, // Config / Client wins over context
}, "pacs.example:2762", "ANY-SCP")
// Or via context (shared with godicom.ReadFileContext etc.)
ctx = gonetdicom.WithLogger(ctx, logger)
client, err := dicomweb.NewClient("https://pacs.example/dicom-web",
dicomweb.WithTimeout(30*time.Second),
dicomweb.WithTLSConfig(&tls.Config{MinVersion: tls.VersionTLS12}),
dicomweb.WithLogger(logger),
)Debug records use fixed attribute keys (component, calling_ae, called_ae,
pdu_type_name, command_name, pc_id, message_id, status, …). At Debug
level, AE logs PDU send/recv and DIMSE command summaries (pynetdicom-style).
Optional real-PACS soak (skipped unless env is set):
GONETDICOM_PACS_ADDR=host:11112 GONETDICOM_PACS_AE=ANY-SCP \
go test -tags=integration ./ae -run TestIntegrationCEchoPACS -v| Package | Role |
|---|---|
ae |
Association SCU / SCP, TLS, roles, identity |
dimse |
DIMSE command sets (C- and N- services) |
pdu |
Upper Layer PDUs and PDV fragmentation |
dicomweb |
WADO-RS / STOW-RS / QIDO-RS client + origin MVP |
status |
Named DIMSE status constants |
Bug reports, fixes, and documentation improvements are welcome. Please open an issue or pull request on GitHub.
MIT — see LICENSE.