DICOM coordinates and gamma, illustrated#

A dose value and its position belong together. A file may store voxels from left to right, from right to left, or with rows and columns exchanged. Reordering those values for calculation must preserve which dose belongs at which physical position. It does not move dose within the patient.

This notebook explains that distinction for a physicist who need not read Python. Read the text and figures in order; expand the hidden code to inspect a calculation. All dose fields are synthetic and generated locally. This is the main explanation of the geometry, historical impact and plotting. The separate validation record contains test evidence, fixture provenance, reproduction commands and remaining limitations.

The main examples cover the DICOM definition (section 1), independent checks (section 2), historical errors and edge cases (section 3), gamma and plotting (sections 4–5), and checks on earlier results (section 6). The appendix explains the former interpolation failures; performance is covered separately. Historical claims below concern PyMedPhys 0.41.0; they do not establish the behaviour of every earlier release.

Start with a coordinate–dose pair#

The example below shows three voxels in two array orders. Follow the 10 Gy voxel: its index changes from 0 to 2, but it remains at x = 100 mm. The arrows follow coordinate–dose pairs, not physical motion.

Blue denotes the current conversion, orange the previous conversion, and dashed black lines an independent DICOM calculation. Examples use two Numba threads to suit documentation build hosts.

Hide code cell source

import copy
import time
import warnings

from scipy.special import erf

import numba
import numpy as np
import pydicom
import scipy.ndimage
from matplotlib import colors
from matplotlib import pyplot as plt

import pymedphys
from pymedphys._dicom.coords import coords_in_datasets_are_equal
from pymedphys._gamma.implementation.shell import (
    _grid_distance_bounds,
    _prepare_evaluation_grid,
)
from pymedphys._interp.interp import interp_linear_1d, interp_linear_3d

numba.set_num_threads(min(2, numba.get_num_threads()))

CURRENT, PREVIOUS, TRUTH = "#2a78d6", "#eb6834", "#0b0b0b"
SECONDARY, MUTED = "#52514e", "#898781"
DOSE_CMAP = colors.LinearSegmentedColormap.from_list(
    "dose", ["#fcfcfb", "#cde2fb", "#86b6ef", "#3987e5", "#1c5cab", "#0d366b"]
)
# Gamma diverges at 1: blue passes, red fails, grey sits on the threshold.
GAMMA_CMAP = colors.LinearSegmentedColormap.from_list(
    "gamma", ["#2a78d6", "#f0efec", "#e34948"]
)

plt.rcParams.update(
    {
        "figure.facecolor": "#fcfcfb",
        "axes.facecolor": "#fcfcfb",
        "savefig.facecolor": "#fcfcfb",
        "axes.edgecolor": "#c3c2b7",
        "axes.labelcolor": SECONDARY,
        "axes.titlecolor": TRUTH,
        "axes.titlesize": 11,
        "xtick.color": MUTED,
        "ytick.color": MUTED,
        "xtick.labelcolor": SECONDARY,
        "ytick.labelcolor": SECONDARY,
        "text.color": TRUTH,
        "grid.color": "#e1e0d9",
        "grid.linewidth": 0.8,
        "lines.linewidth": 2,
        "legend.frameon": False,
        "font.size": 11,
        "figure.dpi": 110,
    }
)

Hide code cell source

stored_x = np.array([100.0, 99.0, 98.0])
stored_dose = np.array([10.0, 20.0, 30.0])
(prepared_x,), prepared_dose, algorithm = _prepare_evaluation_grid(
    (stored_x,), stored_dose, "pymedphys"
)
print("as supplied:", stored_x, stored_dose)
print("prepared:   ", prepared_x, prepared_dose, f"(interpolator: {algorithm})")

fig, ax = plt.subplots(figsize=(8, 2.9), layout="constrained")
for level, (xs, doses, label) in enumerate(
    ((stored_x, stored_dose, "as supplied"), (prepared_x, prepared_dose, "prepared"))
):
    height = 1 - level
    for index, (x_value, dose_value) in enumerate(zip(xs, doses)):
        ax.add_patch(
            plt.Rectangle(
                (index - 0.45, height - 0.28),
                0.9,
                0.56,
                facecolor="#cde2fb",
                edgecolor="#fcfcfb",
                linewidth=2,
            )
        )
        ax.text(
            index,
            height,
            f"x = {x_value:g} mm\n{dose_value:g} Gy",
            ha="center",
            va="center",
        )
    ax.text(
        -0.6,
        height,
        f"{label}\nindex 0, 1, 2",
        ha="right",
        va="center",
        color=SECONDARY,
    )
for top_index, x_value in enumerate(stored_x):
    bottom_index = int(np.flatnonzero(prepared_x == x_value)[0])
    ax.annotate(
        "",
        xy=(bottom_index, 0.3),
        xytext=(top_index, 0.7),
        arrowprops={"arrowstyle": "-|>", "color": MUTED},
    )
ax.set_xlim(-2.2, 2.6)
ax.set_ylim(-0.4, 1.4)
ax.axis("off")
ax.set_title("A different array order; the same physical dose")
assert dict(zip(stored_x, stored_dose)) == dict(zip(prepared_x, prepared_dose))
plt.show()
as supplied: [100.  99.  98.] [10. 20. 30.]
prepared:    [ 98.  99. 100.] [30. 20. 10.] (interpolator: pymedphys)
../../_images/ca2aebe312da6c18cc1fb2d37bc97ffed94bd09f0aba68fde0157bb7ec1fa813.png

Patient directions and file terminology#

For the human (BIPED) DICOM patient coordinate system, +x is towards the patient’s left, +y towards the posterior, and +z towards the head. These anatomical directions do not change when the patient is prone or enters the scanner feet first. A common coordinate system is distinct from anatomical registration: this conversion does not align scans or account for patient movement.

Abbreviation

Patient position

HFS / HFP

Head first supine / head first prone

FFS / FFP

Feet first supine / feet first prone

HFDL / HFDR

Head first decubitus left / right (lying on the left / right side)

FFDL / FFDR

Feet first decubitus left / right

The row direction runs along a row, towards increasing column index. The column direction runs towards increasing row index. In contrast, the first value in PixelSpacing is the distance between rows. The equation below makes these pairings explicit.

We write raw data as pixel_array[k, row, column], where k selects a stored slice. After conversion we write dose[i_z, i_y, i_x], whose coordinates are (z[i_z], y[i_y], x[i_x]). These indices describe different array layouts.

The hidden setup builds DICOM datasets and evaluates the standard independently. Distinct stored values identify each voxel, so the checks follow its dose through the reordering.

Hide code cell source

ORIENTATIONS = {
    "HFS": (1, 0, 0, 0, 1, 0),
    "HFP": (-1, 0, 0, 0, -1, 0),
    "FFS": (-1, 0, 0, 0, 1, 0),
    "FFP": (1, 0, 0, 0, -1, 0),
    "HFDL": (0, -1, 0, 1, 0, 0),
    "HFDR": (0, 1, 0, -1, 0, 0),
    "FFDL": (0, 1, 0, 1, 0, 0),
    "FFDR": (0, -1, 0, -1, 0, 0),
}
DOSE_GRID_SCALING = 1e-4  # Gy per stored unit


def make_rtdose(orientation, position, pixels, pixel_spacing, slice_offsets=None):
    """Build an RT Dose dataset from stored pixels in (slice, row, column) order."""
    pixels = np.asarray(pixels)
    slices, rows, columns = pixels.shape
    ds = pydicom.Dataset()
    ds.file_meta = pydicom.dataset.FileMetaDataset()
    ds.file_meta.TransferSyntaxUID = pydicom.uid.ImplicitVRLittleEndian
    ds.Modality = "RTDOSE"
    ds.ImagePositionPatient = [round(float(value), 4) for value in position]
    ds.ImageOrientationPatient = list(ORIENTATIONS[orientation])
    ds.PixelSpacing = [round(float(value), 4) for value in pixel_spacing]
    ds.Rows, ds.Columns, ds.NumberOfFrames = rows, columns, slices
    if slice_offsets is not None:
        ds.GridFrameOffsetVector = [round(float(value), 4) for value in slice_offsets]
    ds.BitsAllocated = ds.BitsStored = 32
    ds.HighBit = 31
    ds.PixelRepresentation = 0
    ds.SamplesPerPixel = 1
    ds.PhotometricInterpretation = "MONOCHROME2"
    ds.DoseUnits, ds.DoseType, ds.DoseSummationType = "GY", "PHYSICAL", "PLAN"
    ds.DoseGridScaling = DOSE_GRID_SCALING
    ds.PixelData = np.ascontiguousarray(pixels, dtype="<u4").tobytes()
    return ds


def voxel_positions(ds):
    """Patient (x, y, z) of every stored voxel, indexed [slice, row, column, xyz].

    Evaluates DICOM PS3.3 C.7.6.2.1.1 and C.8.8.3.2 term by term.
    """
    S = np.array(ds.ImagePositionPatient, dtype=float)
    iop = np.array(ds.ImageOrientationPatient, dtype=float)
    r, c = iop[:3], iop[3:]
    row_spacing, column_spacing = (float(value) for value in ds.PixelSpacing)
    offsets = getattr(ds, "GridFrameOffsetVector", None)
    g = np.atleast_1d(np.array(offsets if offsets else [0.0], dtype=float))
    if g[0] != 0:  # absolute z coordinates rather than offsets
        g = g - S[2]
    k, row, column = np.meshgrid(
        np.arange(g.size), np.arange(ds.Rows), np.arange(ds.Columns), indexing="ij"
    )
    return (
        S
        + (column * column_spacing)[..., None] * r
        + (row * row_spacing)[..., None] * c
        + g[k][..., None] * np.cross(r, c)
    )


def returned_positions(ds, axes_zyx, dose_zyx):
    """Coordinates given to each stored voxel by a conversion, as [voxel, xyz].

    Returns None when the conversion gave axes that do not match the dose shape.
    """
    z, y, x = axes_zyx
    if np.shape(dose_zyx) != (len(z), len(y), len(x)):
        return None
    Z, Y, X = np.meshgrid(z, y, x, indexing="ij")
    stored_index = np.rint(np.ravel(dose_zyx) / DOSE_GRID_SCALING).astype(int)
    positions = np.empty((stored_index.size, 3))
    positions[stored_index] = np.stack([X.ravel(), Y.ravel(), Z.ravel()], axis=-1)
    return positions


def placement_error(ds, axes_zyx, dose_zyx):
    """Largest distance (mm) between each returned voxel and its DICOM position."""
    positions = returned_positions(ds, axes_zyx, dose_zyx)
    if positions is None:
        return np.nan
    true = voxel_positions(ds).reshape(-1, 3)
    return np.max(np.linalg.norm(positions - true, axis=-1))

The historical comparison uses the 0.41.0 coordinate function bodies, with a legacy_ prefix and docstrings omitted. The dose wrapper is simplified: it scales pixel_array directly and omits the original transfer-syntax helper, because every synthetic dataset already declares its transfer syntax. This reproduces coordinate behaviour, not the whole historical dose-loading path.

Hide code cell source

# Reproduced from pymedphys._dicom.coords and pymedphys._dicom.dose in
# PyMedPhys 0.41.0, for comparison only.


def _orientation_is_head_first(orientation_vector, is_decubitus):
    if is_decubitus:
        return np.abs(np.sum(orientation_vector)) != 2

    return np.abs(np.sum(orientation_vector)) == 2


def legacy_xyz_axes_from_dataset(ds, coord_system="DICOM"):
    position = np.array(ds.ImagePositionPatient)
    orientation = np.array(ds.ImageOrientationPatient)

    if not (
        np.array_equal(np.abs(orientation), np.array([1, 0, 0, 0, 1, 0]))
        or np.array_equal(np.abs(orientation), np.array([0, 1, 0, 1, 0, 0]))
    ):
        raise ValueError(
            "Dose grid orientation is not supported. Dose "
            "grid slices must be aligned along the "
            "superoinferior axis of patient."
        )

    is_decubitus = orientation[0] == 0
    is_head_first = _orientation_is_head_first(orientation, is_decubitus)

    row_spacing = float(ds.PixelSpacing[0])
    column_spacing = float(ds.PixelSpacing[1])

    row_range = np.array([row_spacing * i for i in range(ds.Rows)])
    col_range = np.array([column_spacing * i for i in range(ds.Columns)])

    if is_decubitus:
        x_dicom_fixed = orientation[1] * position[1] + col_range
        y_dicom_fixed = orientation[3] * position[0] + row_range
    else:
        x_dicom_fixed = orientation[0] * position[0] + col_range
        y_dicom_fixed = orientation[4] * position[1] + row_range

    if is_head_first:
        z_dicom_fixed = position[2] + np.array(ds.GridFrameOffsetVector)
    else:
        z_dicom_fixed = -position[2] + np.array(ds.GridFrameOffsetVector)

    if coord_system.upper() in ("FIXED", "IEC FIXED", "F"):
        x = x_dicom_fixed
        y = z_dicom_fixed
        z = -np.flip(y_dicom_fixed)

    elif coord_system.upper() in ("DICOM", "D", "PATIENT", "IEC PATIENT", "P"):
        if orientation[0] == 1:
            x = x_dicom_fixed
        elif orientation[0] == -1:
            x = np.flip(x_dicom_fixed)
        elif orientation[1] == 1:
            y_d = x_dicom_fixed
        elif orientation[1] == -1:
            y_d = np.flip(x_dicom_fixed)

        if orientation[4] == 1:
            y_d = y_dicom_fixed
        elif orientation[4] == -1:
            y_d = np.flip(y_dicom_fixed)
        elif orientation[3] == 1:
            x = y_dicom_fixed
        elif orientation[3] == -1:
            x = np.flip(y_dicom_fixed)

        if not is_head_first:
            z_d = np.flip(z_dicom_fixed)
        else:
            z_d = z_dicom_fixed

        if coord_system.upper() in ("DICOM", "D"):
            y = y_d
            z = z_d
        elif coord_system.upper() in ("PATIENT", "IEC PATIENT", "P"):
            y = z_d
            z = -np.flip(y_d)

    return (x, y, z)


def legacy_zyx_and_dose_from_dataset(dataset):
    x, y, z = legacy_xyz_axes_from_dataset(dataset)
    coords = (z, y, x)
    dose = dataset.pixel_array * dataset.DoseGridScaling

    return coords, dose


def legacy_coords_in_datasets_are_equal(datasets):
    # Quick shape (sanity) check
    if not all(
        ds.pixel_array.shape == datasets[0].pixel_array.shape for ds in datasets
    ):
        return False

    # Full coord check:
    all_concat_axes = [
        np.concatenate(legacy_xyz_axes_from_dataset(ds)) for ds in datasets
    ]

    return all(np.allclose(a, all_concat_axes[0]) for a in all_concat_axes)

1. From stored pixels to patient coordinates#

DICOM defines the patient position of the stored voxel in slice \(k\), row \(r_i\), and column \(c_i\) of pixel_array[k, row, column] as

\[ \mathbf{P}_{k,r_i,c_i} = \mathbf{S} + c_i\,\Delta_c\,\hat{\mathbf{r}} + r_i\,\Delta_r\,\hat{\mathbf{c}} + g_k\,\hat{\mathbf{n}}, \qquad \hat{\mathbf{n}} = \hat{\mathbf{r}} \times \hat{\mathbf{c}}, \]

where:

  • \(\mathbf{S}\) is ImagePositionPatient, the centre of the first stored voxel;

  • \(\hat{\mathbf{r}}\) and \(\hat{\mathbf{c}}\) are the first and second triplets of ImageOrientationPatient, the directions of increasing column index \(c_i\) and increasing row index \(r_i\);

  • \(\Delta_r\) is PixelSpacing[0], the spacing between rows, and \(\Delta_c\) is PixelSpacing[1], the spacing between columns;

  • \(g_k\) is GridFrameOffsetVector[k], the offset of slice \(k\) along \(\hat{\mathbf{n}}\). A vector whose first element is not zero holds absolute z coordinates instead, which the standard permits only for ImageOrientationPatient \(= (1, 0, 0, 0, 1, 0)\), with the first element equal to \(S_z\); subtracting \(S_z\) converts it to relative offsets. A positive offset follows the normal; decreasing offsets reverse traversal.

Equivalently, as a homogeneous matrix,

\[\begin{split} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} \Delta_c r_x & \Delta_r c_x & n_x & S_x \\ \Delta_c r_y & \Delta_r c_y & n_y & S_y \\ \Delta_c r_z & \Delta_r c_z & n_z & S_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} c_i \\ r_i \\ g_k \\ 1 \end{bmatrix}. \end{split}\]

Changing the orientation changes the direction cosines, never the sign of \(\mathbf{S}\). The figure below draws a stored grid of four rows and five columns in each of the eight supported orientations, in the transverse plane as it is usually displayed: patient left to the right, posterior downwards.

Sources: DICOM voxel position, pixel spacing, and RT Dose slice offsets.

Hide code cell source

fig, axs = plt.subplots(
    2, 4, figsize=(11, 7.2), sharex=True, sharey=True, layout="constrained"
)
spacing, rows, columns = 6.0, 4, 5
for ax, (name, iop) in zip(axs.flat, ORIENTATIONS.items()):
    r, c = np.array(iop[:3], dtype=float), np.array(iop[3:], dtype=float)
    # Choose S so that the grid is centred on the origin.
    S = -spacing * ((columns - 1) / 2 * r + (rows - 1) / 2 * c)
    j, i = np.meshgrid(np.arange(columns), np.arange(rows))
    centres = S + spacing * (j[..., None] * r + i[..., None] * c)
    ax.scatter(centres[..., 0], centres[..., 1], s=16, color=MUTED, zorder=2)
    ax.scatter(S[0], S[1], marker="*", s=220, color=TRUTH, zorder=4)
    for vector, label, count in ((r, "columns", columns), (c, "rows", rows)):
        end = S[:2] + (count - 1) * spacing * vector[:2]
        ax.annotate(
            "",
            xy=end,
            xytext=S[:2],
            arrowprops={
                "arrowstyle": "-|>",
                "color": TRUTH,
                "lw": 1.6,
                "shrinkA": 6,
                "shrinkB": 0,
            },
            zorder=3,
        )
        horizontal = {1: "left", -1: "right", 0: "center"}[int(vector[0])]
        vertical = {1: "top", -1: "bottom", 0: "center"}[int(vector[1])]
        ax.text(
            *(end + 0.45 * spacing * vector[:2]),
            label,
            ha=horizontal,
            va=vertical,
            fontsize=9,
        )
    n = np.cross(r, c)
    ax.text(
        0.03,
        0.03,
        f"positive offset: {'+z (head)' if n[2] > 0 else '−z (feet)'}",
        transform=ax.transAxes,
        color=SECONDARY,
        fontsize=9,
    )
    ax.set_title(name)
    ax.set_aspect("equal")
    ax.set_xlim(-30, 30)
    ax.set_ylim(-26, 26)
axs[0, 0].invert_yaxis()
for ax in axs[1]:
    ax.set_xlabel("x (mm), towards patient left")
for ax in axs[:, 0]:
    ax.set_ylabel("y (mm), towards posterior")
fig.suptitle(
    "Stored voxel centres in each orientation; ★ marks the first stored voxel, S"
)
plt.show()
../../_images/b4587d8fb570ea9fd12998fa0dd61f7abf987bf0b36d2f3929be1eb17fd4dd69.png

For every supported orientation, \(\hat{\mathbf{r}}\), \(\hat{\mathbf{c}}\), and \(\hat{\mathbf{n}}\) are signed unit vectors along the patient axes, so the matrix is a signed permutation with scaling. Each patient coordinate therefore depends on exactly one stored index. The table below is computed from ImageOrientationPatient alone; with increasing slice offsets it matches the table in the validation note.

Hide code cell source

def direction(vector):
    axis = int(np.argmax(np.abs(vector)))
    return f"{'+' if vector[axis] > 0 else '-'}{'xyz'[axis]}"


print(f"{'orientation':<12}{'columns run':<14}{'rows run':<11}{'positive offset'}")
for name, iop in ORIENTATIONS.items():
    r, c = np.array(iop[:3]), np.array(iop[3:])
    print(f"{name:<12}{direction(r):<14}{direction(c):<11}{direction(np.cross(r, c))}")
orientation columns run   rows run   positive offset
HFS         +x            +y         +z
HFP         -x            -y         +z
FFS         -x            +y         -z
FFP         +x            -y         -z
HFDL        -y            +x         +z
HFDR        +y            -x         +z
FFDL        +y            +x         -z
FFDR        -y            -x         -z

1.1 How PyMedPhys applies the definition#

Because the matrix is a signed permutation, PyMedPhys evaluates one axis per patient coordinate instead of a coordinate for every voxel: \(x\), \(y\), and \(z\) each come from the single stored index they depend on, in \(O(\text{rows} + \text{columns} + \text{slices})\) operations. pymedphys.dicom.zyx_and_dose_from_dataset then

  1. transposes the stored (slice, row, column) dimensions into patient \((z, y, x)\) order, which swaps rows and columns for the decubitus orientations; and

  2. reverses each axis that decreases, together with the matching dimension of the dose.

The result satisfies dose[i_z, i_y, i_x] \(\leftrightarrow\) (z[i_z], y[i_y], x[i_x]) with every axis ascending. Nothing is interpolated: each stored value is moved, unchanged, to the array position that matches its coordinates. Below, each stored voxel of a small grid is labelled with its storage index, so the rearrangement can be read directly.

Hide code cell source

labels = np.arange(3 * 4 * 5).reshape(3, 4, 5)
fig, axs = plt.subplots(2, 2, figsize=(11, 7.4), layout="constrained")
for row, name in enumerate(["HFP", "HFDL"]):
    ds = make_rtdose(name, (40.0, -30.0, 12.0), labels, (2.0, 3.0), (0.0, 2.5, 5.0))
    (z, y, x), dose = pymedphys.dicom.zyx_and_dose_from_dataset(ds)
    returned = np.rint(dose[0] / DOSE_GRID_SCALING).astype(int)

    ax = axs[row, 0]
    ax.imshow(ds.pixel_array[0], cmap=DOSE_CMAP, vmin=-8, vmax=labels.max())
    for (i, j), value in np.ndenumerate(ds.pixel_array[0]):
        ax.text(j, i, value, ha="center", va="center", fontsize=9)
    ax.set_xticks(range(ds.Columns))
    ax.set_yticks(range(ds.Rows))
    ax.set_xlabel("stored column index")
    ax.set_ylabel("stored row index")
    ax.set_title(f"{name}: pixel_array[0], stored order {ds.pixel_array.shape}")

    ax = axs[row, 1]
    dx, dy = x[1] - x[0], y[1] - y[0]
    ax.imshow(
        returned,
        cmap=DOSE_CMAP,
        vmin=-8,
        vmax=labels.max(),
        extent=(x[0] - dx / 2, x[-1] + dx / 2, y[-1] + dy / 2, y[0] - dy / 2),
    )
    for (jy, ix), value in np.ndenumerate(returned):
        ax.text(x[ix], y[jy], value, ha="center", va="center", fontsize=9)
    ax.set_xticks(x)
    ax.set_yticks(y)
    ax.set_xlabel("x (mm)")
    ax.set_ylabel("y (mm)")
    ax.set_title(f"{name}: returned dose[0] at z = {z[0]:g} mm {dose.shape}")
fig.suptitle(
    "Stored voxels (left) and the same voxels after zyx_and_dose_from_dataset (right)"
)
plt.show()
../../_images/7f6fa06a7a8e2b5b1687535a8766fd967b455f1378a282a897cdb87840c4d93b.png

Voxel 0 is the first stored voxel, at \(\mathbf{S} = (40, -30, 12)\) mm. For head first prone the columns run towards \(-x\) and the rows towards \(-y\), so both in-plane axes are reversed and voxel 0 moves to the corner with the largest \(x\) and \(y\). For head first decubitus left the rows run along \(+x\) and the columns along \(-y\), so the dimensions are also swapped and the returned array has shape (3, 5, 4) rather than (3, 4, 5).

2. The current conversion places every voxel correctly#

2.1 Every voxel against the DICOM definition#

The first check converts a grid with an off-centre origin, unequal row and column spacing, unequal numbers of rows and columns, and unevenly spaced slices, stored in each orientation with increasing and with decreasing slice offsets. It reports the largest distance between any returned voxel and the position that voxel_positions calculates for it.

Hide code cell source

position, shape, pixel_spacing = (123.0, -187.0, 55.0), (3, 4, 5), (2.0, 3.0)
labels = np.arange(np.prod(shape)).reshape(shape)

print(f"{'orientation':<13}{'slice offsets':<15}{'current':<11}previous")
for name in ORIENTATIONS:
    for order, offsets in (
        ("increasing", (0.0, 2.5, 6.0)),
        ("decreasing", (0.0, -2.5, -6.0)),
    ):
        ds = make_rtdose(name, position, labels, pixel_spacing, offsets)
        current = placement_error(ds, *pymedphys.dicom.zyx_and_dose_from_dataset(ds))
        previous = placement_error(ds, *legacy_zyx_and_dose_from_dataset(ds))
        assert current < 1e-9, (name, order, current)
        previous_text = (
            "axes did not fit the dose" if np.isnan(previous) else f"{previous:.1f} mm"
        )
        print(f"{name:<13}{order:<15}{f'{current:g} mm':<11}{previous_text}")
orientation  slice offsets  current    previous
HFS          increasing     0 mm       0.0 mm
HFS          decreasing     0 mm       0.0 mm
HFP          increasing     0 mm       446.3 mm
HFP          decreasing     0 mm       446.3 mm
FFS          increasing     0 mm       256.5 mm
FFS          decreasing     0 mm       261.2 mm
FFP          increasing     0 mm       394.2 mm
FFP          decreasing     0 mm       397.3 mm
HFDL         increasing     0 mm       axes did not fit the dose
HFDL         decreasing     0 mm       axes did not fit the dose
HFDR         increasing     0 mm       axes did not fit the dose
HFDR         decreasing     0 mm       axes did not fit the dose
FFDL         increasing     0 mm       axes did not fit the dose
FFDL         decreasing     0 mm       axes did not fit the dose
FFDR         increasing     0 mm       axes did not fit the dose
FFDR         decreasing     0 mm       axes did not fit the dose

The current conversion places every voxel exactly: the differences are zero, not merely small, because both calculations add the same signed multiples of the spacing to the same origin. The previous conversion was exact only for head first supine. For the other non-decubitus orientations it displaced the grid by hundreds of millimetres, and for the decubitus orientations its axes did not even have the lengths of the dose dimensions they were paired with.

2.2 Randomised grids#

The same comparison over 800 random grids, 100 per orientation, with 2 to 8 voxels along each dimension, spacings from 0.2 mm to 5 mm, origins within ±1500 mm, and uneven slice offsets that increase or decrease. Some head first supine grids use absolute slice offsets.

Hide code cell source

rng = np.random.default_rng(2066)
current_errors = {name: [] for name in ORIENTATIONS}
previous_errors = {name: [] for name in ORIENTATIONS}
for name in ORIENTATIONS:
    for _ in range(100):
        shape = tuple(int(n) for n in rng.integers(2, 9, size=3))
        spacing = np.round(rng.uniform(0.2, 5.0, size=2), 2)
        position = np.round(rng.uniform(-1500, 1500, size=3), 2)
        steps = np.round(rng.uniform(0.2, 5.0, size=shape[0] - 1), 2)
        offsets = np.concatenate([[0.0], np.cumsum(steps)])
        if rng.random() < 0.5:
            offsets = -offsets
        if name == "HFS" and rng.random() < 0.3:
            offsets = offsets + position[2]  # absolute z coordinates
        ds = make_rtdose(
            name, position, np.arange(np.prod(shape)).reshape(shape), spacing, offsets
        )
        current_errors[name].append(
            placement_error(ds, *pymedphys.dicom.zyx_and_dose_from_dataset(ds))
        )
        previous_errors[name].append(
            placement_error(ds, *legacy_zyx_and_dose_from_dataset(ds))
        )

worst_current = max(max(errors) for errors in current_errors.values())
assert worst_current < 1e-9
print(f"Largest current placement error in 800 grids: {worst_current:g} mm")

fig, ax = plt.subplots(figsize=(11, 4.6), layout="constrained")
jitter = np.random.default_rng(0)
for index, name in enumerate(ORIENTATIONS):
    current = np.array(current_errors[name])
    ax.scatter(
        index - 0.2 + jitter.uniform(-0.12, 0.12, current.size),
        current,
        s=12,
        color=CURRENT,
        alpha=0.7,
        linewidths=0,
        label="current" if index == 0 else None,
    )
    errors = np.array(previous_errors[name])
    fitted = errors[np.isfinite(errors)]
    ax.scatter(
        index + 0.2 + jitter.uniform(-0.12, 0.12, fitted.size),
        fitted,
        s=12,
        color=PREVIOUS,
        alpha=0.7,
        linewidths=0,
        label="previous" if index == 0 else None,
    )
    unfit = int(np.sum(~np.isfinite(errors)))
    if unfit:
        ax.text(
            index + 0.2,
            3,
            f"{unfit} of 100:\naxes did not\nfit the dose",
            ha="center",
            va="center",
            fontsize=8,
            color=SECONDARY,
        )
ax.set_yscale("symlog", linthresh=1)
ax.set_ylim(-0.3, 1.5e4)
ax.set_xticks(range(len(ORIENTATIONS)), list(ORIENTATIONS))
ax.set_ylabel("largest voxel displacement (mm)")
ax.grid(axis="y")
ax.legend(loc="upper left", title="conversion")
ax.set_title("Largest voxel displacement in 100 random grids per orientation")
plt.show()
Largest current placement error in 800 grids: 0 mm
../../_images/72fa0927a24cbed5ea49ae45401606094dd24cf5952c6848a4311c15b9454f1a.png

The vertical scale is linear from 0 to 1 mm and logarithmic above 1 mm, so zero and large errors can share a panel. These deliberately broad synthetic origins stress the calculation; this is not a survey of clinical errors.

Head first supine grids with relative offsets were placed exactly before as well; the displaced head first supine grids are those with absolute slice offsets (section 3.4). Decubitus grids with equal numbers of rows and columns were accepted by the previous conversion but transposed in the transverse plane, so they appear as finite displacements.

2.3 One dose distribution, eight storage orders#

A stronger test starts from a known physical dose distribution and stores it in each orientation. The storage directions below are tabulated by hand from section 1, and the encoding uses neither voxel_positions nor PyMedPhys. The distribution is shaped like the letter F, which has no mirror or rotational symmetry, so any flip or transposition would be visible. It also rises asymmetrically along z, so a slice reversal changes the pattern. Its edges come from a continuous Gaussian-smoothed field evaluated at the sample coordinates; a requested 3 mm shift remains 3 mm even on a 2.5 mm grid.

Hide code cell source

def image_extent(x, y):
    """imshow extent for ascending axes, with y increasing downwards."""
    dx, dy = x[1] - x[0], y[1] - y[0]
    return (x[0] - dx / 2, x[-1] + dx / 2, y[-1] + dy / 2, y[0] - dy / 2)


def letter_f(y, x, shift_x=0.0, centre=None):
    """Sample an analytic Gaussian-smoothed F, with a physical x shift in mm.

    Integrate over three non-overlapping rectangles instead of rasterising
    their boundaries. The amplitude is independent of sample positions.
    """
    Y, X = np.meshgrid(y, x, indexing="ij")
    xc, yc = (np.mean(x), np.mean(y)) if centre is None else centre
    X, Y = X - xc - shift_x, Y - yc
    sigma = 2.5

    def interval(t, low, high):
        return 0.5 * (
            erf((t - low) / (np.sqrt(2) * sigma))
            - erf((t - high) / (np.sqrt(2) * sigma))
        )

    smooth = (
        interval(X, -15.5, -8.5) * interval(Y, -22, 22)
        + interval(X, -8.5, 14) * interval(Y, -22, -15)
        + interval(X, -8.5, 8) * interval(Y, -3.5, 3.5)
    )
    return 0.1 + 1.9 * smooth


# Stored (slice, row, column) dimensions as patient (z, y, x) dimensions, and
# the direction of travel along each, tabulated by hand from section 1.
STORAGE = {
    "HFS": ((0, 1, 2), (1, 1, 1)),
    "HFP": ((0, 1, 2), (1, -1, -1)),
    "FFS": ((0, 1, 2), (-1, 1, -1)),
    "FFP": ((0, 1, 2), (-1, -1, 1)),
    "HFDL": ((0, 2, 1), (1, 1, -1)),
    "HFDR": ((0, 2, 1), (1, -1, 1)),
    "FFDL": ((0, 2, 1), (-1, 1, 1)),
    "FFDR": ((0, 2, 1), (-1, -1, -1)),
}


def encode(orientation, axes_zyx, pixels_zyx, reverse_slices=False):
    """Store a physical grid, given with ascending (z, y, x) axes, in an orientation."""
    dimensions, signs = STORAGE[orientation]
    stored = np.transpose(pixels_zyx, dimensions)
    stored_axes = []
    for dimension, (patient_dimension, sign) in enumerate(zip(dimensions, signs)):
        axis = axes_zyx[patient_dimension]
        if sign < 0:
            stored = np.flip(stored, axis=dimension)
            axis = axis[::-1]
        stored_axes.append(axis)
    if reverse_slices:
        stored, stored_axes[0] = stored[::-1], stored_axes[0][::-1]
    position = np.empty(3)
    for patient_dimension, axis in zip(dimensions, stored_axes):
        position[2 - patient_dimension] = axis[0]
    return make_rtdose(
        orientation,
        position,
        stored,
        pixel_spacing=[abs(axis[1] - axis[0]) for axis in stored_axes[1:]],
        slice_offsets=(stored_axes[0] - stored_axes[0][0]) * signs[0],
    )


# The hand-written table must agree with ImageOrientationPatient.
for name, (dimensions, signs) in STORAGE.items():
    r, c = np.array(ORIENTATIONS[name][:3]), np.array(ORIENTATIONS[name][3:])
    for vector, dimension, sign in zip((np.cross(r, c), c, r), dimensions, signs):
        axis = int(np.argmax(np.abs(vector)))
        assert dimension == 2 - axis and sign == np.sign(vector[axis]), name
print("The hand-tabulated storage directions agree with ImageOrientationPatient.")
The hand-tabulated storage directions agree with ImageOrientationPatient.

Hide code cell source

axes_true = (
    15.0 + 3.0 * np.arange(5),
    -56.0 + 2.0 * np.arange(32),
    -8.75 + 2.5 * np.arange(32),
)
z_true, y_true, x_true = axes_true
profile_z = np.array([0.35, 0.50, 0.70, 0.85, 1.00])  # asymmetric
pixels_true = np.rint(
    letter_f(y_true, x_true)[None] * profile_z[:, None, None] / DOSE_GRID_SCALING
).astype(np.uint32)
dose_true = pixels_true * DOSE_GRID_SCALING
middle = 2  # the central slice, z = 21 mm, in every storage order

encoded = {name: encode(name, axes_true, pixels_true) for name in ORIENTATIONS}
recovered = []
for name in ORIENTATIONS:
    for reverse_slices in (False, True):
        axes, dose = pymedphys.dicom.zyx_and_dose_from_dataset(
            encode(name, axes_true, pixels_true, reverse_slices)
        )
        recovered.append(
            all(np.array_equal(a, b) for a, b in zip(axes, axes_true))
            and np.array_equal(dose, dose_true)
        )
assert all(recovered)
print(
    f"{sum(recovered)} of {len(recovered)} encodings recover exactly the same axes and dose"
)

for group in (list(encoded)[:4], list(encoded)[4:]):
    fig, axs = plt.subplots(2, 4, figsize=(10, 5.4), layout="constrained")
    for column, name in enumerate(group):
        ds = encoded[name]
        ax = axs[0, column]
        ax.imshow(
            ds.pixel_array[middle] * DOSE_GRID_SCALING, cmap=DOSE_CMAP, vmin=0, vmax=2
        )
        ax.set_title(name)
        ax.set_xticks([])
        ax.set_yticks([])
        (z, y, x), dose = pymedphys.dicom.zyx_and_dose_from_dataset(ds)
        ax = axs[1, column]
        shown = ax.imshow(
            dose[middle], cmap=DOSE_CMAP, vmin=0, vmax=2, extent=image_extent(x, y)
        )
        ax.set_xticks([0, 50])
        ax.set_yticks([-50, 0])
        ax.set_xlabel("x (mm)")
    axs[0, 0].set_ylabel("stored array\n(row, column)")
    axs[1, 0].set_ylabel("returned dose\ny (mm)")
    fig.colorbar(shown, ax=axs, label="dose (Gy)", shrink=0.75)
    fig.suptitle("Different storage orders; identical returned dose at z = 21 mm")
    plt.show()

# A sagittal section makes the reversal in z visible.
x_index = int(np.argmin(abs(x_true - (x_true.mean() - 12))))
ffs = encoded["FFS"]
(z, y, x), ffs_dose = pymedphys.dicom.zyx_and_dose_from_dataset(ffs)
fig, axs = plt.subplots(1, 2, figsize=(9, 4), layout="constrained")
axs[0].imshow(
    ffs.pixel_array[:, :, len(x) - 1 - x_index] * DOSE_GRID_SCALING,
    cmap=DOSE_CMAP,
    vmin=0,
    vmax=2,
    aspect="auto",
    origin="lower",
)
axs[0].set(
    xlabel="stored row index",
    ylabel="stored slice index k",
    title="FFS: stored sagittal section",
)
shown = axs[1].pcolormesh(
    y, z, ffs_dose[:, :, x_index], cmap=DOSE_CMAP, vmin=0, vmax=2, shading="nearest"
)
axs[1].set(xlabel="y (mm)", ylabel="z (mm)", title="Same samples on patient axes")
fig.colorbar(shown, ax=axs, label="dose (Gy)")
fig.suptitle(f"Asymmetric dose along z; fixed x = {x[x_index]:g} mm")
plt.show()
print(
    "FFS: stored slice k=0 is at z=27 mm; k=4 is at z=15 mm. Both vertical axes increase upwards here."
)
16 of 16 encodings recover exactly the same axes and dose
../../_images/6ebc789b8fbbc051269f5577942a659631d881710ab141c2c488a60d629abd15.png ../../_images/f03e91b65fccfca5078deae127c86fecaf62fabcfb19c7e19030f3b4826d8b02.png ../../_images/913125bd5cffbde93e85eb445e413820ede96e9ea1b42ba59eae1b6a596b770f.png
FFS: stored slice k=0 is at z=27 mm; k=4 is at z=15 mm. Both vertical axes increase upwards here.

The stored images differ by rotations, reflections, and transpositions; the returned arrays are identical to each other and to the original, bit for bit, in every orientation and for both slice orders.

2.4 Where the previous conversion placed the same dose#

The figure projects the central slice’s isodose at 50% of the whole-volume maximum onto the xy plane. The previous and correct placements can have different z coordinates: both appear in the panel titles. This compares in-plane placement; it is not an overlay on one common anatomical slice.

Hide code cell source

from matplotlib.lines import Line2D

level = [0.5 * dose_true.max()]
fig, axs = plt.subplots(
    2, 4, figsize=(11, 6.5), sharex=True, sharey=True, layout="constrained"
)
for ax, (name, ds) in zip(axs.flat, encoded.items()):
    (z, y, x), dose = pymedphys.dicom.zyx_and_dose_from_dataset(ds)
    ax.contour(x, y, dose[middle], levels=level, colors=CURRENT, linewidths=3, zorder=2)
    (z_old, y_old, x_old), stored = legacy_zyx_and_dose_from_dataset(ds)
    # The previous pairing: stored rows with y_old and stored columns with x_old.
    ax.contour(
        x_old,
        y_old,
        stored[middle],
        levels=level,
        colors=PREVIOUS,
        linewidths=2,
        zorder=3,
    )
    ax.contour(
        x_true,
        y_true,
        dose_true[middle],
        levels=level,
        colors=TRUTH,
        linestyles="dashed",
        linewidths=1.2,
        zorder=4,
    )
    if name == "HFS":
        ax.text(0, 30, "all three coincide", ha="center", color=SECONDARY)
    ax.set_title(
        f"{name}: projected onto xy\nold z = {z_old[middle]:g}; true z = {z[middle]:g} mm"
    )
    ax.set_aspect("equal")
    ax.grid(True)
axs[0, 0].set_xlim(-75, 75)
axs[0, 0].set_ylim(60, -60)
for ax in axs[1]:
    ax.set_xlabel("x (mm)")
for ax in axs[:, 0]:
    ax.set_ylabel("y (mm)")
fig.legend(
    handles=[
        Line2D(
            [],
            [],
            color=TRUTH,
            linestyle="dashed",
            linewidth=1.2,
            label="DICOM position",
        ),
        Line2D([], [], color=CURRENT, label="current conversion"),
        Line2D([], [], color=PREVIOUS, label="previous conversion"),
    ],
    loc="outside lower center",
    ncols=3,
)
fig.suptitle("Central-slice 50% isodose, projected onto xy")
plt.show()
../../_images/0e54ec25dd159a66a5f3738f68013cfa2ac3c0f084f2710f09e73e17d50c69b7.png

The current conversion (blue) coincides with the DICOM position (dashed) in every orientation. The previous conversion (orange):

  • placed head first supine correctly;

  • for the uniformly spaced head first prone, feet first supine, and feet first prone grids shown here, reflected the grid centre through the origin along each reversed axis, so the dose was translated, not mirrored, by twice the centre coordinate: \(x' = x - 2c_x\) on a reversed x axis, with \(c_x\) the centre of the grid;

  • for the decubitus orientations, also transposed the transverse plane, because it paired the stored rows with \(y\) and the columns with \(x\).

Why the translation? For a uniformly spaced reversed axis the true coordinates in storage order are \(S - j\Delta\). The previous code formed the image-aligned axis \(-S + j\Delta\), appropriate for its IEC fixed output, and then reversed the array for DICOM output. Reversing restores the direction of travel but not the sign of the origin, so storage index \(j\) received \(-S + (n - 1 - j)\Delta\), which differs from the truth by \((n - 1)\Delta - 2S = -2c\).

3. Edge cases#

3.1 Cropping and shifting a grid#

Because the error is \(-2c\), it depends on where the grid is and how far it extends. The smallest example is a single head first prone row of five 1 mm columns starting at \(x = 100\) mm, with and without its last two columns.

Hide code cell source

def hfp_row(columns):
    return make_rtdose(
        "HFP", (100.0, 0.0, 0.0), np.arange(columns)[None, None, :], (1.0, 1.0), [0.0]
    )


for label, ds in (("five columns", hfp_row(5)), ("three columns", hfp_row(3))):
    true_x = voxel_positions(ds)[0, 0, :, 0]
    previous_x = legacy_xyz_axes_from_dataset(ds)[0]
    (_, _, current_x), dose = pymedphys.dicom.zyx_and_dose_from_dataset(ds)
    stored = np.rint(dose[0, 0] / DOSE_GRID_SCALING).astype(int)
    print(f"{label}:")
    print(f"  DICOM x of stored columns 0, 1, ...    {true_x}")
    print(
        f"  previous x of stored columns 0, 1, ... {previous_x}   error {previous_x[0] - true_x[0]:+g} mm"
    )
    print(f"  current ascending x {current_x}, holding stored columns {stored}")
five columns:
  DICOM x of stored columns 0, 1, ...    [100.  99.  98.  97.  96.]
  previous x of stored columns 0, 1, ... [ -96.  -97.  -98.  -99. -100.]   error -196 mm
  current ascending x [ 96.  97.  98.  99. 100.], holding stored columns [4 3 2 1 0]
three columns:
  DICOM x of stored columns 0, 1, ...    [100.  99.  98.]
  previous x of stored columns 0, 1, ... [ -98.  -99. -100.]   error -198 mm
  current ascending x [ 98.  99. 100.], holding stored columns [2 1 0]

The previous error is \(-196\) mm for the full row and \(-198\) mm for the cropped one, so the two grids, both head first prone, disagree by 2 mm. A common constant translation preserves every reference/evaluation distance, so that particular coordinate error cancels in gamma. Merely sharing metadata does not establish this: uneven reversed slices can distort distances, and decubitus errors can alter in-plane geometry. A shared error also does not repair the former interpolator. Crops, different extents and shifts need their own checks.

The next figure repeats this on a three-dimensional head first prone grid with a smoothed 50 mm × 40 mm field: once with 10 mm removed from its \(+x\) edge, and once with the whole grid, and its dose, moved 15 mm towards \(+x\).

Hide code cell source

def box_field(z, y, x, centre_x=25.0):
    """A smoothed 50 mm x 40 mm field with a gentle wedge, in Gy."""
    Z, Y, X = np.meshgrid(z, y, x, indexing="ij")
    inside = (np.abs(X - centre_x) <= 25) & (np.abs(Y) <= 20)
    spacing = [axis[1] - axis[0] for axis in (z, y, x)]
    smooth = scipy.ndimage.gaussian_filter(
        inside.astype(float), [3.0 / s for s in spacing]
    )
    return 0.05 + 1.9 * smooth * (1 + 0.004 * (X - centre_x)) * np.exp(
        -0.5 * (Z / 15.0) ** 2
    )


def profile_at(axes_zyx, dose_zyx, y0=0.0, z0=0.0):
    """x axis and dose along the stored row and slice labelled nearest to (y0, z0)."""
    z, y, x = axes_zyx
    return x, dose_zyx[np.argmin(np.abs(z - z0)), np.argmin(np.abs(y - y0)), :]


def true_profile(ds, y0=0.0, z0=0.0):
    """DICOM x positions and dose of the stored voxels nearest to (y0, z0)."""
    positions = voxel_positions(ds)
    k = np.argmin(np.abs(positions[:, 0, 0, 2] - z0))
    i = np.argmin(np.abs(positions[k, :, 0, 1] - y0))
    return positions[k, i, :, 0], ds.pixel_array[k, i, :] * DOSE_GRID_SCALING


def x_at_half_maximum(x, profile):
    """Position of the rising 50% edge, whichever way x runs."""
    order = np.argsort(x)
    x, profile = x[order], profile[order]
    rising = np.argmax(profile >= 0.5 * profile.max())
    return np.interp(
        0.5 * profile.max(),
        profile[rising - 1 : rising + 1],
        x[rising - 1 : rising + 1],
    )


box_axes = (
    -10.0 + 2.5 * np.arange(9),
    -40.0 + 2.5 * np.arange(33),
    -30.0 + 2.5 * np.arange(41),
)
box_pixels = np.rint(box_field(*box_axes) / DOSE_GRID_SCALING).astype(np.uint32)
bz, by, bx = box_axes
full = encode("HFP", box_axes, box_pixels)
cropped = encode("HFP", (bz, by, bx[:-4]), box_pixels[..., :-4])
shifted = encode("HFP", (bz, by, bx + 15.0), box_pixels)

conversions = {
    "previous": (legacy_zyx_and_dose_from_dataset, PREVIOUS),
    "current": (pymedphys.dicom.zyx_and_dose_from_dataset, CURRENT),
}
cases = {
    "crop": (cropped, "10 mm removed from the +x edge"),
    "shift": (shifted, "grid moved 15 mm towards +x"),
}
fig, axs = plt.subplots(2, 2, figsize=(12, 7.4), sharey=True, layout="constrained")
for row, (case, (changed, description)) in enumerate(cases.items()):
    for column, (label, (convert, colour)) in enumerate(conversions.items()):
        ax = axs[row, column]
        edge = {}
        for ds, style, name in (
            (full, "-", "original grid"),
            (changed, "--", description),
        ):
            x, profile = profile_at(*convert(ds))
            ax.plot(x, profile, style, color=colour, label=name)
            ax.plot(*true_profile(ds), ":", color=TRUTH, linewidth=1)
            edge[name] = x_at_half_maximum(x, profile)
        moved = edge[description] - edge["original grid"]
        outcome = "did not move" if abs(moved) < 0.5 else f"moved {moved:+.0f} mm"
        ax.set_title(f"{label} conversion, {case}: the dose {outcome}")
        ax.legend(loc="upper left", ncols=2)
        ax.set_ylim(0, 2.6)
        ax.grid(True)
        ax.set_xlim(-110, 110)
for ax in axs[1]:
    ax.set_xlabel("x (mm)")
for ax in axs[:, 0]:
    ax.set_ylabel("dose (Gy) at y = 0, z = 0")
fig.suptitle("Head first prone profiles; dotted black lines show the DICOM positions")
plt.show()
../../_images/9064fb5c63df25571a02439f11761fd51f8805c91246a9550d12c7c986d8e659.png

The current conversion reports the crop as no movement and the shift as \(+15\) mm, as it should. The previous conversion moved the cropped dose by 10 mm and moved the shifted dose by \(-15\) mm, in the wrong direction; both previous profiles also sit far from their true positions (dotted).

Gamma inherits these errors when its coordinates come from the conversion. Below, the cropped grid is the reference and the full grid the evaluation, at 3%/3 mm with a 10% lower dose cutoff. The previous coordinates are passed to the current pymedphys.gamma, which accepts their descending axes; with its default interpolator, version 0.41.0 raised an error for this evaluation grid instead (Appendix A), and with interp_algo="scipy" it returned the same values as below.

For a reference position \(\mathbf{r}\), gamma balances spatial separation and dose difference:

\[ \gamma(\mathbf{r}) = \min_{\mathbf{e}} \sqrt{ \left(\frac{\|\mathbf{e}-\mathbf{r}\|}{\Delta d}\right)^2 + \left(\frac{D_{\mathrm{eval}}(\mathbf{e})-D_{\mathrm{ref}}(\mathbf{r})}{\Delta D}\right)^2}. \]

Here \(\mathbf{e}\) ranges over the interpolated evaluation field, \(\Delta d=3\) mm, and global \(\Delta D=0.03\max(D_{\mathrm{ref}})\). A value \(\gamma\leq1\) passes these criteria. Examples use global normalisation unless labelled local. A 10% cutoff excludes reference points below \(0.10\max(D_{\mathrm{ref}})\).

Blank map pixels are NaN: reference points below the cutoff, which gamma does not analyse. Every analysed point receives a value. Earlier releases could also leave points that the search failed to reach as NaN, so the check below still compares the number of finite results with the number eligible above the cutoff. A finite value alone is not a pass. The colour scale ends at 2, and with max_gamma=2 larger computed values are also reported as 2. The maps show one slice; the quoted pass rate below covers the whole reference volume.

Hide code cell source

gamma_options = {
    "dose_percent_threshold": 3,
    "distance_mm_threshold": 3,
    "lower_percent_dose_cutoff": 10,
    "max_gamma": 2,
}
fig, axs = plt.subplots(1, 2, figsize=(12, 4.6), layout="constrained")
for ax, (label, (convert, colour)) in zip(axs, conversions.items()):
    axes_reference, dose_reference = convert(cropped)
    axes_evaluation, dose_evaluation = convert(full)
    gamma = pymedphys.gamma(
        axes_reference,
        dose_reference,
        axes_evaluation,
        dose_evaluation,
        **gamma_options,
    )
    eligible = dose_reference >= 0.10 * dose_reference.max()
    valid = gamma[eligible & np.isfinite(gamma)]
    assert valid.size == int(eligible.sum()), "Unresolved eligible reference points"
    if label == "current":
        np.testing.assert_allclose(valid, 0, rtol=0, atol=1e-10)
    print(f"{label}: {valid.size}/{eligible.sum()} eligible points have finite gamma")
    z, y, x = axes_reference
    k = np.argmin(np.abs(z))
    mesh = ax.pcolormesh(
        x, y, gamma[k], shading="nearest", cmap=GAMMA_CMAP, vmin=0, vmax=2
    )
    ax.contour(
        x,
        y,
        dose_reference[k],
        levels=[0.5 * dose_reference.max()],
        colors=TRUTH,
        linewidths=1,
    )
    ax.set_aspect("equal")
    ax.set_xlim(-65, 65)  # common physical extent for direct comparison
    ax.set_ylim(max(y) + 1.25, min(y) - 1.25)
    ax.set_xlabel("x (mm), as reported by the conversion")
    ax.set_ylabel("y (mm)")
    ax.set_title(
        f"{label}: {100 * np.mean(valid <= 1):.1f}% pass\nwhole-volume rate; finite points above cutoff"
    )
fig.colorbar(mesh, ax=axs, label="global γ (3%/3 mm), colour capped at 2")
fig.suptitle(
    "Gamma of the cropped grid against the full grid, z = 0; black line: 50% isodose of the reference"
)
plt.show()
previous: 4347/4347 eligible points have finite gamma
current: 4347/4347 eligible points have finite gamma
../../_images/b825c31e6f58da6e1531cafd286ad51d16a8e3534695eff8edda164f533d2453.png

With the current conversion, gamma of a grid against its own crop is zero at every eligible reference point. With the previous one, the artificial 10 mm shift fails the high-gradient edges.

Uneven slices need a different error model#

For a feet-first grid with relative offsets \(g_k\), the true slice coordinate is \(S_z-g_k\). The former converter attached \(-S_z+g_{N-1-k}\) to slice k, giving error

\[\delta z_k=-2S_z+g_k+g_{N-1-k}.\]

This is constant only when the paired offset sums are constant, as with evenly spaced slices. For \(S_z=55\) mm and offsets \((0,2.5,6)\) mm, the errors are \((-104,-105,-104)\) mm: successive separations have been exchanged. Two fields with these same grid headers can therefore have different gamma values after the distortion. This example isolates the coordinate effect by using the current SciPy gamma path for both calculations.

Hide code cell source

true_z = np.array([55, 52.5, 49.0])
previous_z = np.array([-49, -52.5, -55.0])
np.testing.assert_array_equal(previous_z - true_z, [-104, -105, -104])
reference_values, evaluation_values = np.ones(3), np.array([1.0, 0.0, 0.0])
coordinate_gammas = [
    pymedphys.gamma(
        (axis,),
        reference_values,
        (axis,),
        evaluation_values,
        3,
        3,
        interp_algo="scipy",
        interp_fraction=100,
        lower_percent_dose_cutoff=0,
    )
    for axis in (true_z, previous_z)
]
assert coordinate_gammas[0][1] < 1 < coordinate_gammas[1][1]
print("Stored slice    True z (mm)    Previous z (mm)    Error (mm)")
for k, (correct, previous) in enumerate(zip(true_z, previous_z)):
    print(f"{k:12d}{correct:15.1f}{previous:19.1f}{previous - correct:14.1f}")
print(
    f"Middle-slice gamma: correct coordinates {coordinate_gammas[0][1]:.3f}; previous coordinates {coordinate_gammas[1][1]:.3f}"
)
Stored slice    True z (mm)    Previous z (mm)    Error (mm)
           0           55.0              -49.0        -104.0
           1           52.5              -52.5        -105.0
           2           49.0              -55.0        -104.0
Middle-slice gamma: correct coordinates 0.841; previous coordinates 1.176

3.2 Crops of every axis in every orientation#

The same comparison, summarised: for each orientation, 10 mm is removed from either end of each patient axis, and the table reports the largest change in where the conversion places the voxels that the two grids share.

Hide code cell source

def legacy_displacement(ds):
    """Displacement (mm) that the previous conversion gave each stored voxel.

    Returns an array indexed [slice, row, column, xyz]. Uses only the geometry
    attributes, so it works without decoding the pixel data. Raises ValueError
    where the previous axes did not fit the stored dose.
    """
    x, y, z = legacy_xyz_axes_from_dataset(ds)
    stored_shape = (
        int(getattr(ds, "NumberOfFrames", 1)),
        int(ds.Rows),
        int(ds.Columns),
    )
    if any(np.ndim(axis) != 1 for axis in (z, y, x)):
        raise ValueError(
            "previous conversion returned a scalar axis for a single slice"
        )
    if (len(z), len(y), len(x)) != stored_shape:
        raise ValueError(
            f"previous axes {(len(z), len(y), len(x))} do not fit the stored dose {stored_shape}"
        )
    Z, Y, X = np.meshgrid(z, y, x, indexing="ij")
    return np.stack([X, Y, Z], axis=-1) - voxel_positions(ds)


def current_displacement(ds):
    """Displacement (mm) that the current conversion gives each stored voxel."""
    labelled = copy.deepcopy(ds)  # label every stored voxel with its storage index
    labelled.PixelData = np.arange(ds.pixel_array.size, dtype="<u4").tobytes()
    true = voxel_positions(ds)
    returned = returned_positions(
        labelled, *pymedphys.dicom.zyx_and_dose_from_dataset(labelled)
    )
    return returned.reshape(true.shape) - true


def largest_relative_shift(ds_a, ds_b, displacement):
    """Largest change in placement of the voxels that two grids share."""
    placed = []
    for ds in (ds_a, ds_b):
        true = voxel_positions(ds).reshape(-1, 3)
        moved = displacement(ds).reshape(-1, 3)
        placed.append({tuple(np.round(t, 6)): m for t, m in zip(true, moved)})
    shared = placed[0].keys() & placed[1].keys()
    return max(np.linalg.norm(placed[0][key] - placed[1][key]) for key in shared)


crop_axes = (
    100.0 + 2.5 * np.arange(12),
    -70.0 + 2.5 * np.arange(24),
    10.0 + 2.5 * np.arange(24),
)
crop_labels = np.zeros((12, 24, 24), dtype=np.uint32)  # relabelled where needed
crops = [(dimension, end) for dimension in (2, 1, 0) for end in ("low", "high")]
previous_shift = np.full((len(ORIENTATIONS), len(crops)), np.nan)
current_shift = np.zeros_like(previous_shift)
for row, name in enumerate(ORIENTATIONS):
    full_grid = encode(name, crop_axes, crop_labels)
    for column, (dimension, end) in enumerate(crops):
        keep = [slice(None)] * 3
        keep[dimension] = slice(4, None) if end == "low" else slice(None, -4)
        axes = tuple(axis[keep[d]] for d, axis in enumerate(crop_axes))
        crop_grid = encode(name, axes, crop_labels[tuple(keep)])
        current_shift[row, column] = largest_relative_shift(
            full_grid, crop_grid, current_displacement
        )
        try:
            previous_shift[row, column] = largest_relative_shift(
                full_grid, crop_grid, legacy_displacement
            )
        except ValueError:
            pass
assert current_shift.max() < 1e-9
print(
    f"Largest current shift over all {current_shift.size} crops: {current_shift.max():g} mm"
)

fig, ax = plt.subplots(figsize=(8, 6.5), layout="constrained")
cmap = DOSE_CMAP.with_extremes(bad="#e1e0d9")
image = ax.imshow(np.ma.masked_invalid(previous_shift), cmap=cmap, vmin=0, vmax=12)
for (row, column), value in np.ndenumerate(previous_shift):
    text = "axes did\nnot fit" if np.isnan(value) else f"{value:.0f} mm"
    ax.text(
        column,
        row,
        text,
        ha="center",
        va="center",
        fontsize=9,
        color="white" if value > 6 else TRUTH,
    )
ax.set_xticks(range(len(crops)), [f"{'zyx'[d]}\n{end}" for d, end in crops])
ax.set_xlabel("patient axis and end from which 10 mm was removed")
ax.set_yticks(range(len(ORIENTATIONS)), list(ORIENTATIONS))
fig.colorbar(image, ax=ax, label="previous shift (mm)")
ax.set_title(
    "Previous conversion: shift between a grid and its 10 mm crop\n(current conversion: 0 mm in all 48 cases)"
)
plt.show()
Largest current shift over all 48 crops: 0 mm
../../_images/b8bdd26c553ca61e27f99d7176d8d9bcf9ceb0cae56d54d99386f03cc274b51f.png

A crop moved the previous placement by the width removed whenever it was taken along a reversed axis, and not otherwise. For decubitus grids, an in-plane crop made the numbers of rows and columns unequal, and the previous axes no longer fitted the dose at all.

3.3 Decreasing slice offsets#

A head first supine grid may list its slices in decreasing z. The previous conversion placed these slices correctly but returned a descending z axis, which the default gamma interpolator of 0.41.0 could not use as an evaluation grid (Appendix A). The current conversion returns an ascending axis with the slices reversed to match.

Hide code cell source

slice_index = np.arange(4)[:, None, None] * np.ones((1, 2, 2), dtype=int)
ds = make_rtdose(
    "HFS", (0.0, 0.0, 30.0), slice_index, (1.0, 1.0), [0.0, -3.0, -6.0, -9.0]
)
(z, _, _), dose = pymedphys.dicom.zyx_and_dose_from_dataset(ds)
print("DICOM z of stored slices 0, 1, 2, 3:", voxel_positions(ds)[:, 0, 0, 2])
print("previous z axis:                  ", legacy_xyz_axes_from_dataset(ds)[2])
print("current z axis:                   ", z)
print(
    "stored slice at each current z:   ",
    np.rint(dose[:, 0, 0] / DOSE_GRID_SCALING).astype(int),
)
DICOM z of stored slices 0, 1, 2, 3: [30. 27. 24. 21.]
previous z axis:                   [30. 27. 24. 21.]
current z axis:                    [21. 24. 27. 30.]
stored slice at each current z:    [3 2 1 0]

3.4 Absolute slice offsets#

When the first element of GridFrameOffsetVector is not zero, the vector holds absolute z coordinates, which is permitted only for ImageOrientationPatient \(= (1, 0, 0, 0, 1, 0)\), with the first element equal to \(S_z\). The previous conversion added them to \(S_z\) as if they were offsets, displacing every slice by \(S_z\).

Hide code cell source

four_slices = np.arange(4 * 2 * 2).reshape(4, 2, 2)
relative = make_rtdose(
    "HFS", (0.0, 0.0, -250.0), four_slices, (1.0, 1.0), [0.0, 3.0, 6.0, 9.0]
)
absolute = make_rtdose(
    "HFS", (0.0, 0.0, -250.0), four_slices, (1.0, 1.0), [-250.0, -247.0, -244.0, -241.0]
)
for label, ds in (("relative offsets", relative), ("absolute offsets", absolute)):
    print(f"{label}: DICOM z {voxel_positions(ds)[:, 0, 0, 2]}")
    print(f"  previous z {legacy_xyz_axes_from_dataset(ds)[2]}")
    print(f"  current z  {pymedphys.dicom.zyx_and_dose_from_dataset(ds)[0][0]}")

prone = make_rtdose(
    "HFP", (0.0, 0.0, -250.0), four_slices, (1.0, 1.0), [-250.0, -247.0, -244.0, -241.0]
)
try:
    pymedphys.dicom.zyx_and_dose_from_dataset(prone)
except ValueError as error:
    print(f"\nAbsolute offsets with another orientation are rejected:\n  {error}")
relative offsets: DICOM z [-250. -247. -244. -241.]
  previous z [-250. -247. -244. -241.]
  current z  [-250. -247. -244. -241.]
absolute offsets: DICOM z [-250. -247. -244. -241.]
  previous z [-500. -497. -494. -491.]
  current z  [-250. -247. -244. -241.]

Absolute offsets with another orientation are rejected:
  GridFrameOffsetVector does not start at zero, so it must hold absolute z coordinates. That form is only valid when Image Orientation (Patient) is [1, 0, 0, 0, 1, 0] and its first element matches the z value of Image Position (Patient) within 0.01 mm. Got orientation [-1.0, 0.0, 0.0, 0.0, -1.0, 0.0], first offset -250.0 and IPP z -250.0.

3.5 Single-slice RT Dose#

A planar RT Dose, such as an export for a detector-array measurement, has one slice. pydicom returns its pixel data as a two-dimensional array, and the standard allows GridFrameOffsetVector to be absent. The previous conversion raised an error without the vector; with a single-valued vector, it returned z as a scalar alongside a two-dimensional dose, which gamma and dicom_dose_interpolate then rejected. The current conversion returns a three-dimensional array with a length-one z axis at \(S_z\).

Gamma treats the length-one z axis of an evaluation dose as a fixed coordinate: it searches only along y and x within the plane, and adds each reference point’s distance from the plane in quadrature. For coplanar grids, the three-dimensional arrays returned by the conversion therefore give the same gamma as the equivalent two-dimensional arrays, and reference points off the plane keep their perpendicular distance. Both are shown below. Earlier releases searched such planes with three-dimensional shells, which met a zero-thickness plane only where rounding happened to land on it, so gamma depended on the coordinate origin (#2070); comparing two-dimensional arrays was then the workaround.

Hide code cell source

plane_y, plane_x = -20.0 + 2.0 * np.arange(21), -30.0 + 2.5 * np.arange(25)
plane = np.rint(letter_f(plane_y, plane_x) / DOSE_GRID_SCALING).astype(np.uint32)[None]
planar = make_rtdose(
    "HFS", (-30.0, -20.0, 42.0), plane, (2.0, 2.5)
)  # no GridFrameOffsetVector
print("pixel_array shape:", planar.pixel_array.shape)
try:
    legacy_zyx_and_dose_from_dataset(planar)
except AttributeError as error:
    print("previous conversion:", type(error).__name__, "-", error)
(z, y, x), dose = pymedphys.dicom.zyx_and_dose_from_dataset(planar)
print("current conversion: dose shape", dose.shape, "with z axis", z)

measured = 1.01 * letter_f(y, x, shift_x=2.5)  # physical shift without wrapping an edge
gamma_2d = pymedphys.gamma(
    (y, x), dose[0], (y, x), measured, 3, 3, lower_percent_dose_cutoff=10
)
valid = gamma_2d[~np.isnan(gamma_2d)]
print(
    f"two-dimensional gamma, 3%/3 mm: {100 * np.mean(valid <= 1):.1f}% of points pass"
)
pixel_array shape: (21, 25)
previous conversion: AttributeError - 'Dataset' object has no attribute 'GridFrameOffsetVector'
current conversion: dose shape (1, 21, 25) with z axis [42.]
two-dimensional gamma, 3%/3 mm: 100.0% of points pass
gamma_3d = pymedphys.gamma(
    (z, y, x), dose, (z, y, x), measured[None], 3, 3, lower_percent_dose_cutoff=10
)
np.testing.assert_allclose(gamma_3d[0], gamma_2d, rtol=0, atol=1e-12, equal_nan=True)
print("three-dimensional single-slice arrays: the same gamma as the 2D arrays")

# The same measured plane 2 mm further along z. Every reference point is then
# 2 mm from the evaluation plane, so its gamma is at least 2/3 at 3 mm. Gamma
# warns that the reference points lie outside the evaluation grid, as intended.
with warnings.catch_warnings():
    warnings.filterwarnings("ignore", ".*outside the evaluation grid", UserWarning)
    gamma_offset = pymedphys.gamma(
        (z, y, x),
        dose,
        (z + 2.0, y, x),
        measured[None],
        3,
        3,
        lower_percent_dose_cutoff=10,
    )
offset_valid = gamma_offset[~np.isnan(gamma_offset)]
assert offset_valid.min() >= 2 / 3 - 1e-12
print(f"evaluation plane 2 mm away: minimum gamma {offset_valid.min():.3f} (at least 2/3)")
three-dimensional single-slice arrays: the same gamma as the 2D arrays
evaluation plane 2 mm away: minimum gamma 0.669 (at least 2/3)

3.6 Supported orientations and rounding#

This implementation supports the eight transverse, axis-aligned orientations above. Other cardinal orientations, such as sagittal and coronal grids, can also have three independent patient axes, but are not supported by this conversion. A genuinely oblique grid needs a more general representation or resampling.

Direction cosines within \(10^{-4}\) of a supported orientation are snapped to cardinal directions when extracting separable axes. This approximation grows in positional effect with grid extent; the exact-placement demonstrations use exact cardinal cosines. Grid equality retains the original cosines when checking physical disagreement (section 3.7). A clearly oblique grid is rejected:

Hide code cell source

angle = np.radians(5)
tilted = make_rtdose(
    "HFS", (0.0, 0.0, 0.0), np.zeros((2, 3, 3)), (1.0, 1.0), [0.0, 1.0]
)
tilted.ImageOrientationPatient = [
    round(np.cos(angle), 6),
    round(np.sin(angle), 6),
    0,
    round(-np.sin(angle), 6),
    round(np.cos(angle), 6),
    0,
]
try:
    pymedphys.dicom.zyx_and_dose_from_dataset(tilted)
except ValueError as error:
    print(error)
Dose grid orientation is not supported. Dose grid slices must be aligned along the superoinferior axis of patient.

3.7 Summing doses needs the same pixel mapping#

Summing RT Dose files, as the experimental Sum Coincident DICOM Doses app does, adds the stored pixel arrays element by element, which is valid only if equal indices mean equal positions. The previous check compared the axis values and the array shape. Below, a head first supine grid and a feet first decubitus left grid describe the same positions, centred on \(z = 0\), but the decubitus grid stores \(x\) along its rows. Their axis values agree, yet adding the arrays adds each dose to its own transpose.

Hide code cell source

sum_axes = (
    -5.0 + 2.5 * np.arange(5),
    -30.0 + 2.5 * np.arange(24),
    -28.75 + 2.5 * np.arange(24),
)
sum_profile = np.exp(-0.5 * (sum_axes[0] / 6.0) ** 2)[:, None, None]
sum_pixels = np.rint(
    letter_f(sum_axes[1], sum_axes[2])[None] * sum_profile / DOSE_GRID_SCALING
).astype(np.uint32)
supine = encode("HFS", sum_axes, sum_pixels)
decubitus = encode("FFDL", sum_axes, sum_pixels, reverse_slices=True)
for ds in (supine, decubitus):
    ds.PatientID = "SYNTHETIC"

assert legacy_coords_in_datasets_are_equal([supine, decubitus])
assert not coords_in_datasets_are_equal([supine, decubitus])
print(
    "previous check, coordinates equal:",
    legacy_coords_in_datasets_are_equal([supine, decubitus]),
)
print(
    "current check, coordinates equal: ",
    coords_in_datasets_are_equal([supine, decubitus]),
)

stored_sum = (supine.pixel_array + decubitus.pixel_array) * DOSE_GRID_SCALING
_, supine_dose = pymedphys.dicom.zyx_and_dose_from_dataset(supine)
_, decubitus_dose = pymedphys.dicom.zyx_and_dose_from_dataset(decubitus)
fig, axs = plt.subplots(1, 2, figsize=(9, 4.2), layout="constrained")
extent = image_extent(sum_axes[2], sum_axes[1])
for ax, image, title in (
    (axs[0], stored_sum[2], "Stored arrays added\n(previous check accepted them)"),
    (axs[1], (supine_dose + decubitus_dose)[2], "Doses added on\nthe patient axes"),
):
    shown = ax.imshow(image, cmap=DOSE_CMAP, vmin=0, vmax=4, extent=extent)
    ax.set_title(title)
    ax.set_xlabel("x (mm)")
axs[0].set_ylabel("y (mm)")
fig.colorbar(shown, ax=axs, label="dose (Gy)")
fig.suptitle("Two copies of the same dose, z = 0")
plt.show()
previous check, coordinates equal: True
current check, coordinates equal:  False
../../_images/6b7d697d996be4ea124fcf2aaaed3e25c69ae7d781b0a40756fe4c4fc82fd57d.png

The current check combines origin, spacing and the original encoded direction cosines into the maximum 3D distance between corresponding voxel centres, across every pair of datasets. Matching coordinate sets alone does not justify adding raw arrays.

Purpose

Rule used here

Floating-point arithmetic

A separate 10⁻⁹ mm allowance at the decision boundaries

Small physical mismatch

Accept silently through 0.01 mm; warn but still treat as coincident above 0.01 mm through 0.1 mm; reject larger mismatches

Clinical significance

Not determined by this geometry check

Acceptance does not resample either grid or establish mathematical identity. The warning describes a geometric discrepancy, not a judgement that it is clinically negligible. Absolute-offset metadata consistency remains a separate 0.01 mm rule. Unlike the old relative tolerance, these physical limits do not grow with distance from the origin.

4. How gamma treats axis order#

pymedphys.gamma(axes_reference, dose_reference, axes_evaluation, dose_evaluation, ...) uses its two grids differently.

  • The reference grid supplies the points at which gamma is evaluated. It is used as given, in either order, and gamma is returned with the shape and index order of dose_reference: gamma[i_z, i_y, i_x] belongs to the same position as dose_reference[i_z, i_y, i_x].

  • The evaluation grid is interpolated on shells of increasing radius around each reference point. The default interpolator needs strictly ascending, evenly spaced axes, so gamma reverses each descending evaluation axis together with the matching dimension of the evaluation dose before the search starts. Unevenly spaced evaluation axes are passed to the SciPy interpolator instead, with a warning. An evaluation axis with a single value is a fixed coordinate rather than an axis to interpolate along: the search moves along the other axes only, and the distance from the plane, line or point is added (section 3.5).

4.1 Keep coordinates with their dose#

The opening diagram showed this operation: descending evaluation axes are reversed with the matching dose dimension. The reference axes retain their supplied order.

4.2 Storage order does not change gamma#

Below, the same evaluation dose is supplied in four storage orders. The four gamma results are identical, element for element. A reference supplied with a descending axis gives the same values in its own order, so it must be plotted against its own axes.

Hide code cell source

g_y, g_x = -24.0 + 1.0 * np.arange(49), -28.0 + 1.0 * np.arange(57)
reference_2d = letter_f(g_y, g_x)
evaluation_2d = 1.02 * letter_f(g_y, g_x, shift_x=2.5)
options_2d = {
    "dose_percent_threshold": 3,
    "distance_mm_threshold": 2,
    "lower_percent_dose_cutoff": 10,
}

orders = {
    "evaluation, ascending": (slice(None), slice(None)),
    "evaluation, x descending": (slice(None), slice(None, None, -1)),
    "evaluation, y descending": (slice(None, None, -1), slice(None)),
    "evaluation, both descending": (slice(None, None, -1), slice(None, None, -1)),
}
gammas = {
    label: pymedphys.gamma(
        (g_y, g_x),
        reference_2d,
        (g_y[rows], g_x[columns]),
        evaluation_2d[rows, columns],
        **options_2d,
    )
    for label, (rows, columns) in orders.items()
}
first = gammas["evaluation, ascending"]
assert all(np.array_equal(g, first, equal_nan=True) for g in gammas.values())
print(
    "all four evaluation orders give identical gamma:",
    all(np.array_equal(g, first, equal_nan=True) for g in gammas.values()),
)

reversed_reference = pymedphys.gamma(
    (g_y, g_x[::-1]), reference_2d[:, ::-1], (g_y, g_x), evaluation_2d, **options_2d
)
assert np.array_equal(reversed_reference[:, ::-1], first, equal_nan=True)
print(
    "reference with x descending gives the same values in its own order:",
    np.array_equal(reversed_reference[:, ::-1], first, equal_nan=True),
)

panels = [
    (
        label.replace("evaluation, ", ""),
        evaluation_2d[rows, columns],
        gammas[label],
        g_x,
    )
    for label, (rows, columns) in orders.items()
]
# Two pairs per figure remain legible within a documentation column.
for pair in (panels[:2], panels[2:]):
    fig, axs = plt.subplots(2, 2, figsize=(8, 7), layout="constrained")
    for column, (label, supplied, gamma_values, x_axis) in enumerate(pair):
        axs[0, column].imshow(supplied, cmap=DOSE_CMAP, vmin=0, vmax=2.1)
        axs[0, column].set(
            title=f"Evaluation: {label}", xlabel="stored column", ylabel="stored row"
        )
        mesh = axs[1, column].pcolormesh(
            x_axis,
            g_y,
            gamma_values,
            shading="nearest",
            cmap=GAMMA_CMAP,
            vmin=0,
            vmax=2,
        )
        axs[1, column].set(
            aspect="equal",
            ylim=(g_y.max() + 0.5, g_y.min() - 0.5),
            xlabel="reference x (mm)",
            ylabel="reference y (mm)",
        )
    fig.colorbar(mesh, ax=axs[1], label="global γ (3%/2 mm), colour capped at 2")
    fig.suptitle(
        "Same evaluation dose: +2.5 mm in x and +2% dose\nDifferent supplied arrays (top), identical physical gamma (bottom)"
    )
    plt.show()

fig, axs = plt.subplots(1, 2, figsize=(8, 3.8), layout="constrained")
axs[0].imshow(reference_2d[:, ::-1], cmap=DOSE_CMAP, vmin=0, vmax=2.1)
axs[0].set(
    title="Reference supplied with x descending",
    xlabel="stored column",
    ylabel="stored row",
)
mesh = axs[1].pcolormesh(
    g_x[::-1],
    g_y,
    reversed_reference,
    shading="nearest",
    cmap=GAMMA_CMAP,
    vmin=0,
    vmax=2,
)
axs[1].set(
    aspect="equal",
    ylim=(g_y.max() + 0.5, g_y.min() - 0.5),
    title="Gamma on that reference's axes",
    xlabel="reference x (mm)",
    ylabel="reference y (mm)",
)
fig.colorbar(mesh, ax=axs[1], label="global γ (3%/2 mm)")
plt.show()
all four evaluation orders give identical gamma: True
reference with x descending gives the same values in its own order: True
../../_images/0b17a050b118593f4597cf16784f2d412b4ba5cc8273600d8f1f0d08188b09af.png ../../_images/471955a8021a0f98380d7783d11ca759ba98ea6e99c90b21bb2823b03e3c1879.png ../../_images/89524ca215b2053de77827bee87dc9a5708bf83e3529f985e149baf3f59c804c.png

4.3 The search is bounded by the two grids#

The search starts at the smallest distance between the reference and evaluation grids and stops beyond the largest, or at max_gamma times the largest distance threshold if that is smaller. Grids that do not overlap still have valid gamma values: below, every reference point lies at least 4 mm from the evaluation grid, so the search starts at 4 mm, and every gamma value is at least \(4/3\) at 3 mm.

Hide code cell source

reference_x = np.arange(0.0, 10.1, 1.0)
evaluation_x = np.arange(14.0, 30.1, 1.0)
nearest, farthest = _grid_distance_bounds((reference_x,), (evaluation_x,))
print(f"closest possible distance {nearest:g} mm, farthest {farthest:g} mm")
reference_1d, evaluation_1d = 1.0 + 0.01 * reference_x, 1.0 + 0.01 * evaluation_x
gamma_1d = pymedphys.gamma(
    (reference_x,),
    reference_1d,
    (evaluation_x,),
    evaluation_1d,
    3,
    3,
    lower_percent_dose_cutoff=0,
)

fig, (top, bottom) = plt.subplots(
    2, 1, figsize=(8, 5), sharex=True, layout="constrained"
)
top.plot(
    reference_x, reference_1d, "o-", color=CURRENT, markersize=5, label="reference grid"
)
top.plot(
    evaluation_x,
    evaluation_1d,
    "s-",
    color=MUTED,
    markersize=5,
    label="evaluation grid",
)
top.set_ylabel("dose (Gy)")
top.legend(loc="upper left")
bottom.plot(
    reference_x,
    gamma_1d,
    "o-",
    color=CURRENT,
    markersize=5,
    label="γ at each reference point",
)
bottom.plot(
    reference_x,
    (evaluation_x[0] - reference_x) / 3,
    ":",
    color=TRUTH,
    linewidth=1.2,
    label="distance to the evaluation grid / 3 mm",
)
bottom.set_xlabel("x (mm)")
bottom.set_ylabel("γ (3%/3 mm)")
bottom.legend(loc="upper right")
for ax in (top, bottom):
    ax.grid(True)
fig.suptitle("Gamma between grids that do not overlap")
plt.show()
closest possible distance 4 mm, farthest 30 mm
/tmp/ipykernel_1313/156867632.py:6: UserWarning: 11 of 11 analysed reference points (100.0%) lie more than one search step (0.3) outside the evaluation grid. Each is compared with the evaluation data up to the grid's edge, so these points may fail. To analyse only the region the evaluation grid covers, crop the reference grid to it.
  gamma_1d = pymedphys.gamma(
../../_images/cd9d4a3db45f20a9cb72beaaee9748ff84ae2c94d904f73211ef9b4c4dc6e47e.png

5. Plotting gamma after the reordering#

Gamma is returned on the reference grid, so plot it against the reference axes: gamma[i_z, i_y, i_x] is at (z_ref[i_z], y_ref[i_y], x_ref[i_x]), exactly like dose_reference[i_z, i_y, i_x]. The example below uses a head first prone reference and a head first decubitus right evaluation of the same F, shifted by 3 mm and scaled by 2%.

To overlay gamma on the stored pixel data instead, rearrange it into stored order first. to_storage_order applies the inverse of the conversion’s transposition and reversals, using only ImageOrientationPatient and the direction of the slice offsets.

Hide code cell source

def to_storage_order(array_zyx, ds):
    """Rearrange an array on the zyx_and_dose_from_dataset grid into stored order."""
    iop = np.rint(np.array(ds.ImageOrientationPatient, dtype=float))
    r, c = iop[:3], iop[3:]
    offsets = getattr(ds, "GridFrameOffsetVector", None)
    offsets = np.atleast_1d(np.array(offsets if offsets else [0.0], dtype=float))
    slice_sign = -1 if offsets.size > 1 and offsets[1] < offsets[0] else 1
    # Direction of travel along the stored slices, rows, and columns.
    directions = (slice_sign * np.cross(r, c), c, r)
    stored = np.transpose(
        array_zyx, [2 - int(np.argmax(np.abs(d))) for d in directions]
    )
    for dimension, d in enumerate(directions):
        if d[np.argmax(np.abs(d))] < 0:
            stored = np.flip(stored, axis=dimension)
    return stored


round_trips = [
    np.array_equal(
        to_storage_order(pymedphys.dicom.zyx_and_dose_from_dataset(ds)[1], ds),
        ds.pixel_array * DOSE_GRID_SCALING,
    )
    for name in ORIENTATIONS
    for ds in (
        encode(name, axes_true, pixels_true),
        encode(name, axes_true, pixels_true, reverse_slices=True),
    )
]
assert all(round_trips)
print(
    f"to_storage_order restores pixel_array exactly for {sum(round_trips)} of {len(round_trips)} encodings"
)
to_storage_order restores pixel_array exactly for 16 of 16 encodings

Hide code cell source

# Verify translation before sampling and DICOM quantisation.
centre = (x_true.mean(), y_true.mean())
np.testing.assert_allclose(
    letter_f(y_true, x_true + 3, shift_x=3, centre=centre),
    letter_f(y_true, x_true, centre=centre),
    rtol=0,
    atol=1e-14,
)
assert not np.array_equal(
    letter_f(y_true, x_true, shift_x=3), letter_f(y_true, x_true, shift_x=2.5)
)
shifted_f = (
    1.02 * letter_f(y_true, x_true, shift_x=3.0)[None] * profile_z[:, None, None]
)
reference = encode("HFP", axes_true, pixels_true)
evaluation = encode(
    "HFDR", axes_true, np.rint(shifted_f / DOSE_GRID_SCALING).astype(np.uint32)
)

axes_reference, dose_reference = pymedphys.dicom.zyx_and_dose_from_dataset(reference)
axes_evaluation, dose_evaluation = pymedphys.dicom.zyx_and_dose_from_dataset(evaluation)
gamma = pymedphys.gamma(
    axes_reference,
    dose_reference,
    axes_evaluation,
    dose_evaluation,
    3,
    2,
    lower_percent_dose_cutoff=10,
)
z_ref, y_ref, x_ref = axes_reference

# Choose the plane by position: z = 21 mm.
k = int(np.argmin(np.abs(z_ref - 21.0)))
k_stored = int(np.argmin(np.abs(voxel_positions(reference)[:, 0, 0, 2] - 21.0)))
gamma_stored = to_storage_order(gamma, reference)
stored_dose = reference.pixel_array * DOSE_GRID_SCALING
levels = [0.25, 1.0, 1.75]

fig, axs = plt.subplots(2, 2, figsize=(11, 10), layout="constrained")
ax = axs[0, 0]
ax.imshow(
    dose_reference[k], cmap=DOSE_CMAP, vmin=0, vmax=2, extent=image_extent(x_ref, y_ref)
)
ax.contour(x_ref, y_ref, dose_reference[k], levels=levels, colors=TRUTH, linewidths=0.8)
ax.set_title("Reference dose on patient axes")
ax = axs[0, 1]
mesh = ax.pcolormesh(
    x_ref, y_ref, gamma[k], shading="nearest", cmap=GAMMA_CMAP, vmin=0, vmax=2
)
ax.contour(x_ref, y_ref, dose_reference[k], levels=levels, colors=TRUTH, linewidths=0.8)
ax.set_ylim(y_ref[-1] + 1, y_ref[0] - 1)
ax.set_title("Correct: gamma on reference axes")
fig.colorbar(mesh, ax=axs[0, 1], label="γ (3%/2 mm)", shrink=0.8)
for ax in axs[0]:
    ax.set_aspect("equal")
    ax.set_xlabel("x (mm)")
    ax.set_ylabel("y (mm)")
for ax, overlay, title in (
    (axs[1, 0], gamma[k], "Incorrect: gamma over raw pixels by index"),
    (axs[1, 1], gamma_stored[k_stored], "Correct: gamma restored to stored order"),
):
    ax.imshow(stored_dose[k_stored], cmap=DOSE_CMAP, vmin=0, vmax=2)
    ax.contour(
        np.ma.masked_invalid(overlay), levels=[1.0], colors="#e34948", linewidths=1.5
    )
    ax.set_title(title)
    ax.set_xlabel("stored column index")
    ax.set_ylabel("stored row index")
fig.suptitle(
    "Head first prone reference, z = 21 mm; red lines mark γ = 1 where gamma is defined"
)
plt.show()
../../_images/492385d020111a25baf45e8e963dd8e11d97523f454aba017a81a34bac18145a.png

In the top row, gamma and the reference dose share the reference axes, and the failing regions follow the edges of the F that run along y, where the 3 mm shift along x matters. In the bottom left, the same gamma array is drawn over the stored pixel data by index: for head first prone the stored rows and columns both run backwards, so the gamma pattern is rotated by 180° relative to the dose. For decubitus orientations the stored array is also transposed and can have a different shape.

In practice:

  • keep each axis tuple with its own array, and index gamma with the reference axes;

  • choose planes by position, for example k = np.argmin(np.abs(z_ref - z_target)), because the evaluation grid has its own axes and a shared index need not mean a shared position;

  • subtract doses only where their sample positions coincide, or after interpolating onto a common grid;

  • to overlay gamma on pixel_array, rearrange gamma into stored order, as to_storage_order does; and

  • treat invert_yaxis and similar calls as display choices: they change the view, not the positions of the voxels.

The red contour marks gamma = 1 only where gamma is finite. It does not close an artificial boundary through points excluded by the cutoff.

6. Checking an earlier calculation#

The helper below checks 0.41.0 geometry and interpolation risks using headers read with pydicom.dcmread(path, stop_before_pixels=True). It reports placement, array representability and suitability of the evaluation axes separately. Set interp_algo="scipy" if that was actually used; the default checks the custom interpolator introduced in 0.41.0. Other versions need a separate historical reproduction.

It samples four corners of every slice rather than allocating a full coordinate volume. Those corners bound placement error because displacement within each slice is affine in row and column. Every slice is retained, since an uneven-offset error can peak internally.

Headers can expose a geometric problem but cannot establish its effect on a particular dose comparison, anatomical registration or clinical decision. Even when placement is correct, uneven evaluation spacing could produce wrong interpolation in the former default implementation.

Hide code cell source

def previous_geometry_report(ds):
    """Header-only placement and representability checks for the 0.41.0 converter."""
    try:
        shape = (int(getattr(ds, "NumberOfFrames", 1)), int(ds.Rows), int(ds.Columns))
        axes_xyz = legacy_xyz_axes_from_dataset(ds)
        if any(np.ndim(axis) != 1 for axis in axes_xyz):
            raise ValueError("single-slice conversion returned a scalar z axis")
        if tuple(len(axis) for axis in axes_xyz[::-1]) != shape:
            raise ValueError("previous axis lengths did not fit the stored dose")
        if any(not np.all(np.isfinite(axis)) for axis in axes_xyz):
            raise ValueError("non-finite historical coordinates")
        x_old, y_old, z_old = axes_xyz
        S = np.asarray(ds.ImagePositionPatient, dtype=float)
        iop = np.asarray(ds.ImageOrientationPatient, dtype=float)
        r, c = iop[:3], iop[3:]
        g = np.atleast_1d(np.asarray(ds.GridFrameOffsetVector, dtype=float))
        if g[0] != 0:
            if not np.array_equal(iop, ORIENTATIONS["HFS"]) or not np.isclose(
                g[0], S[2], rtol=0, atol=0.01
            ):
                raise ValueError(
                    "inconsistent absolute slice offsets; cannot infer valid DICOM geometry"
                )
            g = g - S[2]
        if g.size != shape[0] or not (np.all(np.diff(g) > 0) or np.all(np.diff(g) < 0)):
            raise ValueError("invalid slice count or offset ordering")
        deltas = []
        for row in (0, shape[1] - 1):
            for column in (0, shape[2] - 1):
                actual = (
                    S
                    + column * float(ds.PixelSpacing[1]) * r
                    + row * float(ds.PixelSpacing[0]) * c
                    + g[:, None] * np.cross(r, c)
                )
                previous = np.column_stack(
                    (np.full(g.size, x_old[column]), np.full(g.size, y_old[row]), z_old)
                )
                deltas.append(previous - actual)
        deltas = np.asarray(deltas)
        constant = np.allclose(deltas, deltas[0, 0], rtol=0, atol=1e-6)
        return {
            "axes": axes_xyz,
            "translation": deltas[0, 0] if constant else None,
            "maximum_mm": float(np.linalg.norm(deltas, axis=-1).max()),
            "transposed": bool(iop[0] == 0),
            "slice_dependent": not np.allclose(
                deltas, deltas[:, :1], rtol=0, atol=1e-6
            ),
        }
    except (AttributeError, TypeError, ValueError, IndexError) as error:
        return {"failure": str(error)}


def describe_previous_registration(
    reference, evaluation, interp_algo="pymedphys", version="0.41.0"
):
    """Return structured findings and print a scoped historical diagnostic."""
    if version != "0.41.0" or interp_algo not in ("pymedphys", "scipy"):
        raise ValueError(
            "This example diagnoses 0.41.0 with pymedphys or scipy interpolation only"
        )
    reports = {}
    for role, ds in (("reference", reference), ("evaluation", evaluation)):
        report = previous_geometry_report(ds)
        reports[role] = report
        if "failure" in report:
            print(
                f"  {role}: historical conversion/array representation failed: {report['failure']}"
            )
            continue
        if report["transposed"]:
            print(f"  {role}: transverse row/column pairing was transposed")
        if report["translation"] is not None:
            vector = np.array2string(
                report["translation"], precision=3, suppress_small=True
            )
            print(f"  {role}: constant placement error {vector} mm")
        else:
            detail = (
                "slice-dependent" if report["slice_dependent"] else "index-dependent"
            )
            print(
                f"  {role}: {detail} placement error, up to {report['maximum_mm']:.3f} mm"
            )
    translations = [
        reports[role].get("translation") for role in ("reference", "evaluation")
    ]
    if all(value is not None for value in translations):
        relative = translations[1] - translations[0]
        print(f"  relative translation (evaluation minus reference): {relative} mm")
    else:
        print("  a common-translation cancellation cannot be established")
    evaluation_report = reports["evaluation"]
    risks = []
    if "axes" in evaluation_report:
        axes = evaluation_report["axes"]
        if any(axis.size < 2 for axis in axes):
            risks.append(
                "singleton evaluation axis: historical interpolation/search unreliable"
            )
        if interp_algo == "pymedphys":
            if any(np.any(np.diff(axis) <= 0) for axis in axes):
                risks.append(
                    "descending evaluation axis: error, NaN or non-termination in the former default path"
                )
            if any(
                axis.size > 2
                and not np.allclose(
                    np.diff(axis), np.diff(axis)[0], rtol=1e-6, atol=1e-6
                )
                for axis in axes
            ):
                risks.append(
                    "uneven evaluation spacing: the former default interpolation was incorrect"
                )
    evaluation_report["interpolation_risks"] = risks
    if "axes" not in evaluation_report:
        print("  interpolation not assessed: no usable historical evaluation grid")
    else:
        print(
            f"  0.41.0 {interp_algo} interpolation: "
            + (
                "; ".join(risks)
                if risks
                else "no listed axis risk identified; this is not validation of a dose result"
            )
        )
    return reports


uneven_hfs = make_rtdose("HFS", (0, 0, 0), np.zeros((4, 3, 3)), (1, 1), [0, 1, 2, 4])
uneven_ffs = make_rtdose("FFS", (0, 0, 55), np.zeros((3, 3, 3)), (1, 1), [0, 2.5, 6])
single_slice = make_rtdose("HFS", (0, 0, 42), np.zeros((1, 3, 3)), (1, 1), [0])
examples = {
    "HFS grid and its crop": (
        encode("HFS", box_axes, box_pixels),
        encode("HFS", (bz, by, bx[:-4]), box_pixels[..., :-4]),
    ),
    "HFP grid and its crop": (full, cropped),
    "HFP reference, HFS evaluation": (full, encode("HFS", box_axes, box_pixels)),
    "HFDL reference, HFS evaluation": (encoded["HFDL"], encoded["HFS"]),
    "HFS with uneven offsets": (uneven_hfs, uneven_hfs),
    "FFS with uneven offsets": (uneven_ffs, uneven_ffs),
    "Single slice with scalar offset": (single_slice, single_slice),
}
reports = {}
for label, datasets in examples.items():
    # Verify that the diagnostic works with headers alone.
    headers = [copy.deepcopy(ds) for ds in datasets]
    for ds in headers:
        del ds.PixelData
    print(label)
    reports[label] = describe_previous_registration(*headers)
assert any(
    "uneven" in item
    for item in reports["HFS with uneven offsets"]["evaluation"]["interpolation_risks"]
)
assert reports["FFS with uneven offsets"]["reference"]["slice_dependent"]
assert not reports["FFS with uneven offsets"]["reference"]["transposed"]
assert "failure" in reports["Single slice with scalar offset"]["reference"]
# Further guards: absent offsets and rectangular decubitus grids also report failure.
assert "failure" in previous_geometry_report(planar)
rectangular = make_rtdose("HFDL", (0, 0, 0), np.zeros((2, 3, 4)), (1, 1), [0, 1])
assert "failure" in previous_geometry_report(rectangular)
scipy_report = describe_previous_registration(
    uneven_hfs, uneven_hfs, interp_algo="scipy"
)
assert not scipy_report["evaluation"]["interpolation_risks"]
HFS grid and its crop
  reference: constant placement error [0. 0. 0.] mm
  evaluation: constant placement error [0. 0. 0.] mm
  relative translation (evaluation minus reference): [0. 0. 0.] mm
  0.41.0 pymedphys interpolation: no listed axis risk identified; this is not validation of a dose result
HFP grid and its crop
  reference: constant placement error [-40.   0.   0.] mm
  evaluation: constant placement error [-30.   0.   0.] mm
  relative translation (evaluation minus reference): [10.  0.  0.] mm
  0.41.0 pymedphys interpolation: descending evaluation axis: error, NaN or non-termination in the former default path
HFP reference, HFS evaluation
  reference: constant placement error [-40.   0.   0.] mm
  evaluation: constant placement error [0. 0. 0.] mm
  relative translation (evaluation minus reference): [40.  0.  0.] mm
  0.41.0 pymedphys interpolation: no listed axis risk identified; this is not validation of a dose result
HFDL reference, HFS evaluation
  reference: transverse row/column pairing was transposed
  reference: index-dependent placement error, up to 136.199 mm
  evaluation: constant placement error [0. 0. 0.] mm
  a common-translation cancellation cannot be established
  0.41.0 pymedphys interpolation: no listed axis risk identified; this is not validation of a dose result
HFS with uneven offsets
  reference: constant placement error [0. 0. 0.] mm
  evaluation: constant placement error [0. 0. 0.] mm
  relative translation (evaluation minus reference): [0. 0. 0.] mm
  0.41.0 pymedphys interpolation: uneven evaluation spacing: the former default interpolation was incorrect
FFS with uneven offsets
  reference: slice-dependent placement error, up to 105.019 mm
  evaluation: slice-dependent placement error, up to 105.019 mm
  a common-translation cancellation cannot be established
  0.41.0 pymedphys interpolation: descending evaluation axis: error, NaN or non-termination in the former default path; uneven evaluation spacing: the former default interpolation was incorrect
Single slice with scalar offset
  reference: historical conversion/array representation failed: single-slice conversion returned a scalar z axis
  evaluation: historical conversion/array representation failed: single-slice conversion returned a scalar z axis
  a common-translation cancellation cannot be established
  interpolation not assessed: no usable historical evaluation grid
  reference: constant placement error [0. 0. 0.] mm
  evaluation: constant placement error [0. 0. 0.] mm
  relative translation (evaluation minus reference): [0. 0. 0.] mm
  0.41.0 scipy interpolation: no listed axis risk identified; this is not validation of a dose result

The HFS crop example has no placement discrepancy and evenly spaced evaluation axes. The uneven HFS example also has correct placement but exposes a separate interpolation risk. The uneven FFS example has slice-dependent errors, and the single-slice example reports an array-representation failure. These are distinct findings, not one binary verdict about an earlier result.

7. Remaining limitations#

  • Only the supported transverse cardinal orientations are converted; genuinely oblique geometry needs a different representation (section 3.6).

  • Gamma samples a finite set of search shells, so it approximates the continuous minimum to within the search resolution, the distance threshold divided by interp_fraction. Every analysed reference point receives a value, including points outside the evaluation grid and off a single-slice evaluation plane (section 3.5). Earlier releases could report such points as NaN or with origin-dependent values (#2070).

  • The private IEC patient option raises NotImplementedError; IEC fixed retains its previous convention.

  • Synthetic orientation invariance validates these examples, not anatomical registration, every vendor encoding, or clinical suitability of a gamma criterion.

Appendix A. Why descending evaluation axes failed before#

The interpolation kernels locate a point by testing axis[0] <= point <= axis[-1] and searching the axis in ascending order. On a descending axis no point passes that test, so every interpolated value is the fill value, which gamma sets to infinity:

Hide code cell source

descending = np.array([100.0, 99.0, 98.0])
values = np.array([10.0, 20.0, 30.0])
points = np.array([[98.5], [99.0], [99.5]])
print("descending axis:", interp_linear_1d(descending, values, points, np.inf))
print(
    "ascending axis: ",
    interp_linear_1d(descending[::-1].copy(), values[::-1].copy(), points, np.inf),
)
descending axis: [inf inf inf]
ascending axis:  [25. 20. 15.]

A search that never finds a finite value returned NaN when max_gamma was set, and otherwise never terminated. In version 0.41.0 most affected DICOM grids did not get that far. The previous conversion produced its reversed axes with numpy.flip, which returns a view with negative strides, and passed them to the Numba kernel alongside contiguous axes. Numba cannot iterate over a tuple of arrays with different memory layouts, so the call failed:

Hide code cell source

z_axis = np.array([0.0, 2.5, 5.0])  # contiguous, as for head first prone
y_axis = np.flip(np.array([-6.0, -4.0, -2.0, 0.0]))  # reversed views, as the
x_axis = np.flip(
    np.array([10.0, 12.0, 14.0, 16.0, 18.0])
)  # previous conversion returned
try:
    interp_linear_3d(
        (z_axis, y_axis, x_axis),
        np.zeros((3, 4, 5)),
        np.array([[2.0, -3.0, 13.0]]),
        np.inf,
    )
except numba.core.errors.TypingError as error:
    import re

    plain = re.sub(r"\x1b\[[0-9;]*m", "", str(error))
    message = [line.strip() for line in plain.splitlines() if line.strip()]
    print(f"{type(error).__name__}: {message[0]}\n  {message[1]}")
TypingError: Failed in nopython mode pipeline (step: nopython frontend)
  No implementation of function Function(<class 'enumerate'>) found for signature:

When all three axes were reversed, as for feet first decubitus right, the layouts matched and the search ran without finding a value. The current gamma converts every evaluation axis to a contiguous ascending array before the search, so neither failure can occur.

Further reading: gamma performance#

The background workstation study compares old/new PyMedPhys and SciPy interpolation paths within a two-hour total budget and produces a results bundle with explicit coverage limits. The recorded workstation evidence retains verified partial measurements from the earlier long run. The fixed-grid performance notebook explains the avoided work with a smaller recorded experiment. Both use ascending regular grids to separate performance from the coordinate corrections illustrated here.