Mask layout

Every mask RT-Utils accepts or returns is a 3D boolean array indexed mask[row, column, slice].

Axis

Index

Runs

0

row

down a column, along ImageOrientationPatient[3:6]

1

column

along a row, along ImageOrientationPatient[0:3]

2

slice

through the series in ascending slice position

1 axis 1 · column IOP[0:3] axis 0 · row IOP[3:6] mask[1, 2, k] axis 2 · slice k = 0 k = n ascending slice position

The first two axes are the image grid as pydicom reads it. The third runs through the series in spatial order, not file order and not InstanceNumber order.

Why this order

The first two axes are ordered exactly as pixel_array, so a mask produced by segmenting the series already has the right layout and needs no rearrangement:

import numpy as np

series = rtstruct.series_data
volume = np.stack([s.pixel_array for s in series], axis=-1)
mask = volume > threshold          # ready for add_roi

The mask and the images overlay directly, slice for slice:

plt.imshow(series[k].pixel_array, cmap="gray")
plt.contour(mask[:, :, k])         # no transpose

Axis 2 is spatial order, not file order

RTStruct.series_data is the series sorted by position along the slice normal, ascending. That is the order axis 2 must be in.

It is not necessarily the order the files appear in a directory listing, and it is not InstanceNumber order, which descends in plenty of real acquisitions. If you build a volume yourself, iterate series_data rather than globbing the directory:

# Right: the order rt-utils sorted into
volume = np.stack([s.pixel_array for s in rtstruct.series_data], axis=-1)

# Wrong: whatever order the filesystem returned
volume = np.stack([pydicom.dcmread(f).pixel_array for f in glob("*.dcm")], axis=-1)

A mask stacked in the wrong order writes every contour to the wrong slice and produces a perfectly valid structure set describing the wrong anatomy.

A mask from somewhere else

A mask that came from another tool carries that tool’s conventions. The two things that differ are the axis order and the direction each axis runs in, and a volume can disagree on both at once.

Danger

There is no fixed sequence of transpose, flipud and rot90 that converts correctly. The right rearrangement is whatever the two geometries say it is, and it differs from file to file. That is exactly why advice of that kind works on one dataset and silently fails on the next.

For NIfTI, use the converter, which reads both affines and resamples between them:

from rt_utils.nifti import nifti_to_mask

mask = nifti_to_mask("segmentation.nii.gz", rtstruct.series_data)
rtstruct.add_roi(mask=mask, name="liver")

See From NIfTI.

For anything else, the test is absolute, not relative: pick a voxel you can identify anatomically, and check that the mask marks it on the same slice and at the same place as the image does.

Validation

add_roi() checks the mask before writing anything and raises RTStruct.ROIException if:

  • the dtype is not bool;

  • the array is not 3-dimensional;

  • mask.shape[2] is not the number of slices in the series;

  • mask.shape[:2] is not (Rows, Columns) of the series.

An empty mask is logged, not raised. A structure legitimately absent from a patient is not an error.

Warning

On a square series the first two checks cannot tell a transposed mask from a correct one. 512 × 512 transposed is still 512 × 512. The shape check is a guard against a mask sized for a different series, not a proof of orientation.

History

Before 2.2.0 masks were allocated and validated as (Columns, Rows, Slices) while every geometric computation used the opposite order. On a square series the two are indistinguishable, which is how the discrepancy survived from 2020. On a non-square series it was wrong in both directions: ROIs past the shorter dimension read back as zero voxels, and correct masks were rejected.

If you transposed your masks to work around that on a non-square series, remove the transpose when upgrading to 2.2.0 or later. Square series are unaffected in plane.