Surface meshes & tractography¶
Two related native-geometry objects. Surface Segmentation (SOP
…66.5) stores a segmentation as an actual mesh — points and mesh
primitives — rather than a voxel mask or an encapsulated STL blob.
Tractography Results (SOP …66.6) stores diffusion streamlines as
polylines in Frame-of-Reference world coordinates, with optional
per-track scalars such as FA and ADC. Both are authored and read over the
same model the writer took, so what you get back is close to what you put
in.
Surface Segmentation (66.5)¶
Read a mesh¶
read_surface returns {"surfaces": [...], "segments": [...]}, or
None when the file is not a Surface Segmentation object. Every primitive
type (triangle / strip / fan / facet) is expanded into one flat triangle
list, so each surface arrives as a plain (points, triangles) pair ready
for rendering.
import pydcm
doc = pydcm.read_surface("surface.dcm")
s = doc["surfaces"][0]
s["points"] # (N, 3) float64 vertices, patient mm
s["triangles"] # (M, 3) uint32 vertex indices, 0-based
s["normals"] # (N, 3) float64 per-vertex normals, or None
s["recommended_type"] # "SURFACE" or "POINTS"
s["recommended_opacity"], s["finite_volume"], s["manifold"]
g = doc["segments"][0]
g["label"], g["algorithm_type"]
g["property_type"]["meaning"] # coded, e.g. "Hippocampus"
g["referenced_surface_numbers"] # wire Surface Numbers this segment covers
Coordinates come back as float64 and indices as uint32 (0-based).
normals is None when the file carries none; lines / vertices are
present only when the surface actually uses edge or point primitives.
Author a mesh¶
write_surface(surfaces, segments) takes a list of mesh dicts and a list
of segment dicts. Points are written as Double Point Coordinates, so a
coordinate that needs more than seven digits survives the round trip
rather than being rounded to 32-bit. Triangle indices are 0-based —
the 1-based wire form is the engine's business — and an index off the end
of points is refused, not silently dropped.
import numpy as np
import pydcm
points = np.array([[-123.4567891, 10.0000001, 5.5],
[ 12.3456789, -20.1234567, 5.5],
[ 0.0, 30.9876543, 5.5],
[ 0.0, 0.0, 42.4242424]], np.float64)
triangles = np.array([[0, 1, 2], [0, 1, 3], [1, 2, 3], [2, 0, 3]], np.uint32)
surface = {"points": points, "triangles": triangles,
"finite_volume": True, "manifold": True,
"opacity": 0.75, "rgb": (0, 0, 255), "comments": "left hippocampus"}
segment = {"label": "Hippocampus", "algorithm_type": "SEMIAUTOMATIC",
"property_category": ("T-D000A", "SRT", "Anatomical Structure"),
"property_type": ("T-A2000", "SRT", "Hippocampus"),
"algorithm_family": ("123110", "DCM", "Region Growing"),
"algorithm_name": "grow", "algorithm_version": "1.0",
"surfaces": [0]} # 0-based index into `surfaces`, not a wire number
pydcm.write_surface([surface], [segment], output="surface.dcm",
patient_name="DOE^JANE", patient_id="MRN-001",
study_uid="1.2.3.4.5", study_date="20260624")
Both sequences are Type 1: a document needs at least one surface and at
least one segment. A segment's surfaces entries are 0-based indices into
the list you pass, not the Surface Numbers the reader reports back. Coded
concepts (property_category / property_type / algorithm_family) take
(value, scheme, meaning). Omit output to get Part-10 bytes instead.
A tri-state hint — finite_volume, manifold — is True, False, or
omitted. Omitting it means UNKNOWN, the honest answer for an arbitrary
mesh, and comes back as "UNKNOWN" rather than a claim of "NO".
Point clouds vs surfaces¶
Leave out triangles and the mesh is a point cloud: presentation_type
follows the geometry, so it is authored as POINTS and reads back with
recommended_type == "POINTS" and an empty triangle list. Presenting a
cloud as a surface would draw nothing, so the default is the useful one.
cloud = {"points": points} # no triangles
pydcm.write_surface([cloud], [segment], output="cloud.dcm")
Tractography Results (66.6)¶
Author streamlines¶
write_mktract(reference, track_sets) authors a Tractography Results
object from track sets of polylines. reference is a source-series path
(or list of instance paths) supplying demographics and the Frame of
Reference UID, or None to mint fresh identifiers. Track point
coordinates are in that Frame of Reference — patient world mm. Each track
is an (n, 3) array of xyz; a single dict or a list of dicts is accepted.
import numpy as np
import pydcm
track_a = np.array([[0., 0., 0.], [1., 0., 0.], [2., 0., 0.]], np.float32)
track_b = np.array([[0., 0., 0.], [0., 1., 0.], [0., 1., 1.]], np.float32)
pydcm.write_mktract(
"dwi_series/", # Frame of Reference + demographics
{"label": "Corticospinal", "description": "left CST",
"anatomy": ("T-A2000", "SCT", "Corticospinal tract"),
"algorithm_name": "FACT", "line_thickness": 2.0, "rgb": (220, 30, 30),
"tracks": [track_a, track_b]},
output="tract.dcm")
anatomy defaults to White Matter and diffusion to Single Tensor if
omitted; both take the same coded-concept forms as elsewhere. Leave
output off to get the Part-10 bytes.
Per-track FA / ADC measurements & statistics¶
A track set can carry a scalar sampled along each streamline (a
measurement) and reductions of it (statistics). A measurement's values
is one array per track, paired point-for-point with that track's vertices.
track_statistics is one scalar per track; set_statistics is one scalar
for the whole set.
fa = ("113290", "DCM", "Fractional Anisotropy")
mean = ("R-00317", "SRT", "Mean")
unitless = ("1", "UCUM", "1")
pydcm.write_mktract(
"dwi_series/",
{"label": "CST", "tracks": [track_a, track_b],
"measurements": [
{"concept": fa, "units": unitless,
"values": [np.array([0.61, 0.58, 0.55], np.float32), # along track_a
np.array([0.60, 0.57, 0.54], np.float32)]}], # along track_b
"track_statistics": [
{"concept": fa, "modifier": mean, "units": unitless,
"values": np.array([0.58, 0.57], np.float32)}], # one per track
"set_statistics": [
{"concept": fa, "modifier": mean, "units": unitless, "value": 0.575}]},
output="tract_fa.dcm")
Read tracks back¶
read_tract returns {"track_sets": [...]}, or None when the file is
not a Tractography Results object.
import pydcm
ts = pydcm.read_tract("tract_fa.dcm")["track_sets"][0]
ts["label"], ts["anatomy"]["meaning"], ts["algorithm_name"]
for t in ts["tracks"]:
t["points"] # (N, 3) float32, world mm
for m in ts["measurements"]: # a quantity sampled along the tracks
for per in m["tracks"]: # one entry per track
per["values"] # (N,) float32; per["indices"] if not 1:1
for st in ts["set_statistics"]:
st["concept"]["meaning"], st["value"] # e.g. "Fractional Anisotropy", 0.575
Coordinates stay float32 because that is the width the standard gives
them and a streamline goes to a vertex buffer; statistics come back as
float64 because a statistic is a measurement, not geometry — a set FA of
0.575, not representable in float32, survives exactly.
dipy / MRtrix streamline round-trip
The tracks list — one (n, 3) array per streamline — is exactly the
shape dipy and MRtrix use, so a round trip is a coordinate flip and a
repackage, not a rewrite. One caveat: DICOM Frame-of-Reference space is
LPS (mm), while dipy / nibabel streamlines in RASMM space are
RAS. Negate x and y crossing the boundary.
import numpy as np, pydcm
from dipy.io.streamline import load_tractogram # RASMM streamlines
lps = np.array([-1, -1, 1], np.float32) # RAS(mm) -> LPS(mm)
sft = load_tractogram("tracks.trk", "reference.nii.gz")
tracks = [(s * lps).astype(np.float32) for s in sft.streamlines]
pydcm.write_mktract("dwi_series/", {"label": "WM", "tracks": tracks},
output="tract.dcm")
back = [t["points"] * lps # LPS -> RAS(mm) on the way out
for t in pydcm.read_tract("tract.dcm")["track_sets"][0]["tracks"]]
An MRtrix .tck stores streamlines in the image's real/scanner
coordinates; convert those to the reference series' patient LPS frame
the same way before writing.