gonetdicom

package module
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT Imports: 0 Imported by: 0

README

gonetdicom

Go DICOM networking library — depends on godicom.

Go Version

Scope

Library Role
godicom pydicom port — files, Dataset, pixels, JSON
gonetdicom Network layer — DIMSE (+ DICOMweb); not part of the pydicom port

Behavioural reference for DIMSE: pynetdicom (git submodule pynetdicom/).
DICOMweb follows DICOM PS3.18 (WADO-RS / QIDO-RS / STOW-RS).
DIMSE status codes live in package status (dcm4che-style named constants; Go has no Python-like dynamic lookup).

gonetdicom
 └── github.com/godicom-dev/godicom

Status

Status: v0.12.0 — Phase 1–4 + full DIMSE-N / User Identity / parallel C-MOVE / async Storage Commitment / status / AllStorageSOPClasses / inbound Data+FileMeta; depends on godicom v0.23.0+.

Install

go get github.com/godicom-dev/gonetdicom@latest

Clone with submodule:

git clone --recurse-submodules https://github.com/godicom-dev/gonetdicom.git

Quick start (C-ECHO SCU)

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)
	}
}

C-STORE SCU

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: "1.2.840.10008.5.1.4.1.1.7", // Secondary Capture
		TransferSyntaxes: []string{"1.2.840.10008.1.2"},
	}},
}
assoc, err := ae.Dial(ctx, cfg, "pacs.example:11112", "ANY-SCP")
// ...
ds := godicom.NewDataset()
ds.Set(godicom.NewDataElement(godicom.MustTag("SOPClassUID"), godicom.VRUI, "..."))
// ...
res, err := assoc.CStore(ctx, ae.StoreRequest{
	AffectedSOPClassUID:    "1.2.840.10008.5.1.4.1.1.7",
	AffectedSOPInstanceUID: "1.2.3.4.5",
	Data:                   ds, // encoded via godicom with negotiated TS
})

C-FIND SCU

cfg := ae.Config{
	AETitle: "FINDSCU",
	PresentationContexts: []ae.PresentationContext{{
		ID: 1,
		AbstractSyntax: ae.PatientRootQueryRetrieveInformationModelFind,
		TransferSyntaxes: []string{pdu.ImplicitVRLittleEndian},
	}},
}
assoc, err := ae.Dial(ctx, cfg, "pacs.example:11112", "ANY-SCP")
query := godicom.NewDataset()
query.Set(godicom.NewDataElement(godicom.MustTag("QueryRetrieveLevel"), godicom.VRCS, "PATIENT"))
query.Set(godicom.NewDataElement(godicom.MustTag("PatientID"), godicom.VRLO, "*"))
matches, err := assoc.CFind(ctx, ae.FindRequest{
	QueryModel:     ae.PatientRootQueryRetrieveInformationModelFind,
	IdentifierData: query,
})

C-MOVE / C-GET SCU

// C-MOVE: peer stores to MoveDestination AE; SCU collects status responses.
matches, err := assoc.CMove(ctx, ae.MoveRequest{
	QueryModel:      ae.PatientRootQueryRetrieveInformationModelMove,
	MoveDestination: "STORESCP",
	IdentifierData:  query,
})

// C-GET: peer pushes C-STORE on the same association; handle via OnCStore.
matches, err := assoc.CGet(ctx, ae.GetRequest{
	QueryModel:     ae.PatientRootQueryRetrieveInformationModelGet,
	IdentifierData: query,
	OnCStore: func(_ context.Context, req ae.StoreRequest) uint16 {
		// req.Data is decoded like pynetdicom event.dataset
		_ = req.Data
		return status.Success
	},
})

Propose both the QR Get model and storage SOP Class presentation contexts for C-GET. For 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 + CT Image Storage */ },
	RoleSelections: []pdu.RoleSelection{
		ae.BuildRole(string(uid.CTImageStorage), false, true), // requestor as SCP
	},
}

Storage Commitment (N-ACTION / N-EVENT-REPORT)

Same-association push (SCU waits via OnNEventReport):

res, err := assoc.NAction(ctx, ae.ActionRequest{
	RequestedSOPClassUID:    ae.StorageCommitmentPushModelSOPClass,
	RequestedSOPInstanceUID: ae.StorageCommitmentPushModelSOPInstance,
	ActionTypeID:            dimse.StorageCommitmentActionTypeRequest,
	ActionInformationData:   info, // TransactionUID + ReferencedSOPSequence
	OnNEventReport: func(_ context.Context, req ae.EventReportRequest) uint16 {
		// handle commitment result (EventTypeID 1=success / 2=failures)
		return 0x0000
	},
})

Async new-association push (SCP dials the SCU AE after N-ACTION-RSP — non-blocking for the requestor association):

OnNAction: func(_ context.Context, req ae.ActionRequest) (ae.ActionResult, *ae.EventReportRequest) {
	return ae.ActionResult{Status: 0x0000, /* ... */}, &ae.EventReportRequest{
		AffectedSOPClassUID:    req.RequestedSOPClassUID,
		AffectedSOPInstanceUID: req.RequestedSOPInstanceUID,
		EventTypeID:            dimse.StorageCommitmentEventTypeSuccess,
		EventInformationData:   req.ActionInformationData,
		AsyncDestination: &ae.EventReportDestination{
			Addr: "127.0.0.1:11113", CalledAE: "COMMITSCU",
		},
	}
},

C-STORE SCP

Serve blocks until ctx is cancelled (or the listener fails). Do not reuse the short WithTimeout from the C-ECHO SCU 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)
}
log.Printf("STORESCP listening on %s", ln.Addr())
err = ae.Serve(ctx, ln, ae.ServerConfig{
	AETitle:                  "STORESCP",
	AcceptedAbstractSyntaxes: ae.AllStorageSOPClasses, // pynetdicom AllStoragePresentationContexts
	OnCStore: func(_ context.Context, req ae.StoreRequest) uint16 {
		// pynetdicom: ds = event.dataset; ds.file_meta = event.file_meta; ds.save_as(...)
		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
	},
})
if err != nil {
	log.Fatal(err)
}

C-MOVE SCP (Move Destination C-STORE)

Configure MoveDestinations and return MovePlan.Stores from OnCMove. The SCP dials the destination AE and performs C-STORE sub-operations (pending/final C-MOVE-RSP derived from store outcomes):

_ = 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}, // >1 fans out C-STORE associations
	},
	OnCMove: func(_ context.Context, req ae.MoveRequest) ae.MovePlan {
		return ae.MovePlan{
			Stores: []ae.StoreRequest{{
				AffectedSOPClassUID:    ctUID,
				AffectedSOPInstanceUID: sopUID,
				Data:                   ds,
			}},
		}
	},
})

Status-only handlers can still return MovePlan{Responses: ...} without Stores.

User Identity Negotiation

Propose identity on the SCU; verify (or ignore) on the SCP. Rejection uses A-ASSOCIATE-RJ (result=2, source=2, reason=1) like pynetdicom. Kerberos/SAML/JWT may return a server response AC item when requested:

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.

DIMSE-N (N-GET / N-SET / N-CREATE / N-DELETE)

res, err := assoc.NGet(ctx, ae.NGetRequest{
	RequestedSOPClassUID:    sopClass,
	RequestedSOPInstanceUID: sopInstance,
	AttributeIdentifierList: []dimse.Tag{0x00100010},
})
res, err = assoc.NSet(ctx, ae.SetRequest{
	RequestedSOPClassUID:    sopClass,
	RequestedSOPInstanceUID: sopInstance,
	ModificationListData:    mods,
})
res, err = assoc.NCreate(ctx, ae.CreateRequest{
	AffectedSOPClassUID:    sopClass,
	AffectedSOPInstanceUID: sopInstance,
	AttributeListData:      attrs,
})
res, err = assoc.NDelete(ctx, ae.DeleteRequest{
	RequestedSOPClassUID:    sopClass,
	RequestedSOPInstanceUID: sopInstance,
})

SCP handlers: OnNGet, OnNSet, OnNCreate, OnNDelete (nil → status 0x0110 Processing Failure).

DICOMweb (WADO / STOW / QIDO)

client := &dicomweb.Client{BaseURL: "https://pacs.example/dicom-web"}

// STOW-RS
_, err := client.StoreFiles(ctx, "", []*godicom.FileDataset{fd})

// WADO-RS instance / series / study + metadata
raw, err := client.RetrieveInstance(ctx, studyUID, seriesUID, sopUID)
parts, err := client.RetrieveSeries(ctx, studyUID, seriesUID)
parts, err = client.RetrieveStudy(ctx, studyUID)
meta, err := client.RetrieveInstanceMetadata(ctx, studyUID, seriesUID, sopUID)
// Origin-server metadata JSON uses BulkDataURI for Pixel Data (not InlineBinary).

// WADO-RS rendered (JPEG/PNG) + Pixel Data bulkdata
mt, img, err := client.RetrieveRenderedInstance(ctx, studyUID, seriesUID, sopUID, dicomweb.RenderOptions{
	MediaType: dicomweb.MediaTypeJPEG,
	Quality:   90,
})
bulk, err := client.RetrieveBulkData(ctx, studyUID, seriesUID, sopUID)

// QIDO-RS studies / series / instances
matches, err := client.SearchStudies(ctx, url.Values{"PatientID": {"P001"}})
series, err := client.SearchSeries(ctx, studyUID, url.Values{"Modality": {"CT"}})
instances, err := client.SearchInstances(ctx, studyUID, seriesUID, nil)

Origin-server MVP for tests/demos:

store := dicomweb.NewMemoryStore()
http.ListenAndServe(":8080", dicomweb.Handler(store, "/dicom-web"))

TLS / timeouts / logging (Phase 4)

// DIMSE over TLS
assoc, err := ae.Dial(ctx, ae.Config{
	AETitle:     "MYSCU",
	IdleTimeout: 30 * time.Second,
	TLS:         &tls.Config{ServerName: "pacs.example", MinVersion: tls.VersionTLS12},
	Logger:      slog.Default(),
}, "pacs.example:2762", "ANY-SCP")

// DICOMweb client with TLS + timeout
client, err := dicomweb.NewClient("https://pacs.example/dicom-web",
	dicomweb.WithTimeout(30*time.Second),
	dicomweb.WithTLSConfig(&tls.Config{MinVersion: tls.VersionTLS12}),
	dicomweb.WithLogger(slog.Default()),
)

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

Packages

Package Role
pdu A-ASSOCIATE / P-DATA-TF / A-RELEASE / A-ABORT + PDV fragmentation
dimse C-ECHO / C-STORE / C-FIND / C-MOVE / C-GET / C-CANCEL + N-ACTION / N-EVENT-REPORT
ae Association SCU/SCP + TLS / idle timeout / optional slog / role selection
dicomweb WADO-RS (incl. rendered/bulkdata) / STOW-RS / QIDO-RS client + origin-server MVP

Roadmap (working plan)

Phase 0 — Bootstrap ✅
  • go.mod + remote
  • pynetdicom submodule
  • depend on godicom
Phase 1 — DIMSE foundation (pynetdicom-aligned) ✅
  • Association / AE title / presentation contexts
  • PDU encode/decode
  • C-ECHO SCU (smoke path)
Phase 2 — Core DIMSE services
  • C-STORE SCU/SCP
  • godicom EncodeDataset / DecodeDataset integration
  • C-FIND SCU/SCP (Patient/Study root models)
  • C-MOVE / C-GET SCU/SCP (sub-op counts; C-GET interleaved C-STORE)
  • SCP/SCU Role Selection Negotiation (PDU 0x54, ae.BuildRole)
  • C-CANCEL-RQ (ae.CCancel) for outstanding FIND/MOVE/GET
  • Storage Commitment Push Model (ae.NAction / ae.NEventReport)
Phase 3 — DICOMweb MVP
  • WADO-RS Retrieve Instance (application/dicom) + Metadata (dicom+json)
  • WADO-RS Retrieve Study / Series (+ metadata)
  • WADO-RS Retrieve Rendered (instance JPEG/PNG) + Pixel Data bulkdata
  • STOW-RS Store
  • QIDO-RS Search for Studies / Series / Instances
Phase 4 — Harden
  • Timeouts (IdleTimeout / HTTP client timeout)
  • TLS helpers (DIMSE Config.TLS / ListenAndServeTLS; DICOMweb WithTLSConfig)
  • Optional slog logging
  • Optional real-PACS C-ECHO soak (-tags=integration)
  • Broader fixture / multi-PACS matrix in CI

Layout

gonetdicom/
├── ae/                # Association SCU / SCP
├── dimse/             # DIMSE command sets
├── dicomweb/          # DICOMweb client + origin-server MVP
├── pdu/               # Upper Layer PDUs
├── pynetdicom/        # submodule → pydicom/pynetdicom
├── go.mod
└── README.md

License

MIT — see LICENSE.

Documentation

Overview

Package gonetdicom provides DICOM networking for Go.

It depends on github.com/godicom-dev/godicom for Dataset / file I/O / pixels, and uses [pynetdicom](https://github.com/pydicom/pynetdicom) (git submodule) as the primary behavioural reference for DIMSE. DICOMweb (WADO-RS / QIDO-RS / STOW-RS) follows DICOM PS3.18.

Subpackages:

Directories

Path Synopsis
Package ae provides DICOM Application Entity association (SCU) helpers.
Package ae provides DICOM Application Entity association (SCU) helpers.
Package dicomweb implements a DICOMweb (PS3.18) client and origin-server MVP.
Package dicomweb implements a DICOMweb (PS3.18) client and origin-server MVP.
Package dimse encodes and decodes DIMSE command sets (Implicit VR Little Endian).
Package dimse encodes and decodes DIMSE command sets (Implicit VR Little Endian).
Package pdu implements DICOM Upper Layer Protocol Data Units (PS3.8).
Package pdu implements DICOM Upper Layer Protocol Data Units (PS3.8).
Package status defines DIMSE status codes as named constants.
Package status defines DIMSE status codes as named constants.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL