Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Conformance

on:
push:
branches: ['**']
pull_request:
workflow_dispatch: # manual run from any branch

jobs:
conformance:
name: RTSTRUCT->mask conformance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: setup.py

- name: Install package + conformance extra
run: |
python -m pip install --upgrade pip
pip install -e ".[conformance]"
pip install pytest

- name: Run conformance gate
run: pytest dcmrtstruct2nii/tests/test_conformance.py -v
6 changes: 3 additions & 3 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ on:
jobs:
build:

runs-on: self-hosted
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
python-version: ['3.9', '3.10', '3.11']

steps:
- uses: actions/checkout@v3
Expand All @@ -44,4 +44,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=160 --statistics
- name: Test with pytest
run: |
pytest -vv -s
pytest -vv -s -m "not network"
31 changes: 29 additions & 2 deletions dcmrtstruct2nii/adapters/convert/rtstructcontour2mask.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,30 @@ def _poly2mask(self, coords_x, coords_y, shape):

return mask

def convert(self, rtstruct_contours, dicom_image, mask_background, mask_foreground):
def _resolve_slice_index(self, contour_z_mm, fallback_continuous_z, slice_z_positions):
"""Map a contour's physical Z to a slice index.

When ``slice_z_positions`` (per-DICOM ImagePositionPatient[2]
array in the order ITK stacked the series) is provided, return
the index of the nearest cached slice. This is robust against
non-uniform-Z series where SimpleITK's ``ImageSeriesReader``
compresses per-slice positions into a single averaged
``spacing[2]`` and ``TransformPhysicalPointToIndex`` then rounds
against that average -- causing contours on the irregular side
of the gap to land on the wrong slice.

When ``slice_z_positions`` is None (e.g. IPP tag missing from
an anonymized series), fall back to rounding the continuous
index that ``TransformPhysicalPointToContinuousIndex`` already
produced. This preserves legacy behavior on any input that
previously worked.
"""
if slice_z_positions is None:
return int(round(fallback_continuous_z))
return int(np.argmin(np.abs(np.asarray(slice_z_positions) - contour_z_mm)))

def convert(self, rtstruct_contours, dicom_image, mask_background, mask_foreground,
slice_z_positions=None):
shape = dicom_image.GetSize()

mask = sitk.Image(shape, sitk.sitkUInt8)
Expand Down Expand Up @@ -42,7 +65,11 @@ def convert(self, rtstruct_contours, dicom_image, mask_background, mask_foregrou
pts[index, 1] = world_coords[1]
pts[index, 2] = world_coords[2]

z = int(round(pts[0, 2]))
# Resolve the contour's slice index. For DICOM RT, every point
# in a CLOSED_PLANAR contour shares the same physical Z by
# construction, so the first point's Z is the contour's Z.
contour_z_mm = float(coordinates['z'][0])
z = self._resolve_slice_index(contour_z_mm, pts[0, 2], slice_z_positions)

try:
filled_poly = self._poly2mask(pts[:, 0], pts[:, 1], [shape[0], shape[1]])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def ingest(self, input_file, maskname_pattern, skip_contours=False): # noqa: C9
:return: multidimensional array with ROI(s)
'''
try:
rt_struct_image = pydicom.read_file(input_file)
rt_struct_image = pydicom.dcmread(input_file)

if not hasattr(rt_struct_image, 'StructureSetROISequence'):
raise InvalidDicomError()
Expand Down
55 changes: 50 additions & 5 deletions dcmrtstruct2nii/adapters/input/image/dcminputadapter.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import numpy as np
import pydicom
import SimpleITK as sitk


from dcmrtstruct2nii.adapters.input.abstractinputadapter import AbstractInputAdapter
from dcmrtstruct2nii.exceptions import InvalidFileFormatException


class DcmInputAdapter(AbstractInputAdapter):
def ingest(self, input_dir, series_id=None):
def ingest(self, input_dir, series_id=None, return_slice_z_positions=False):
'''
Load DICOMs from input_dir to a single 3D image and make sure axial
direction is on third axis.

:param input_dir: Input directory where the dicom files are located
:param series_id: Optional, the Series Instance UID for the image dicoms
:return: multidimensional array with pixel data, metadata
:param series_id: Optional, the Series Instance UID for the image
dicoms
:param return_slice_z_positions: When True, also return a numpy
array of per-slice ImagePositionPatient[2] values in the same
order ITK used to stack the series. The array is needed by
the rasterizer to map contour Z's to slice indices on
non-uniform-Z series, where SimpleITK's ImageSeriesReader
collapses ``spacing[2]`` to an averaged value and breaks
``TransformPhysicalPointToIndex``.
:return: SimpleITK image. When return_slice_z_positions=True,
returns ``(image, slice_z_positions)``.
'''
dicom_reader = sitk.ImageSeriesReader()

Expand All @@ -28,4 +39,38 @@ def ingest(self, input_dir, series_id=None):

dicom_image = dicom_reader.Execute()

return dicom_image
if not return_slice_z_positions:
return dicom_image

slice_z_positions = _read_slice_z_positions(dicom_file_names, dicom_image)
return dicom_image, slice_z_positions


def _read_slice_z_positions(dicom_file_names, dicom_image):
"""Read per-slice ImagePositionPatient[2] in the order ITK stacked them.

``GetGDCMSeriesFileNames`` returns files in the order
``ImageSeriesReader`` uses to build the volume. Reading each file's
IPP[2] in that order gives us a per-z-index Z position that we can
use as ground truth when mapping contour Z's to slice indices --
bypassing SimpleITK's averaged ``spacing[2]`` for non-uniform-Z
series.

Returns None if any file is missing the IPP tag (anonymized series,
corrupt input) so the rasterizer cleanly falls back to the legacy
``round(TransformPhysicalPointToIndex)`` path.
"""
try:
zs = []
for path in dicom_file_names:
ds = pydicom.dcmread(path, stop_before_pixels=True, specific_tags=['ImagePositionPatient'])
ipp = getattr(ds, 'ImagePositionPatient', None)
if ipp is None or len(ipp) < 3:
return None
zs.append(float(ipp[2]))
except Exception:
return None

if not zs:
return None
return np.asarray(zs, dtype=np.float64)
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def ingest(self, input_dir):
# we probably want to use this in the future, once we start refactoring: [field for field in dir(dicom) if len(field) > 0 and field[0].isupper()]
dicoms = []
for i in range(0, len(dicom_file_names)):
dicoms.append(pydicom.read_file(dicom_file_names[i]))
dicoms.append(pydicom.dcmread(dicom_file_names[i]))
return dicoms
# we should refactor this so that it maps based on the objects fields and values
except (IsADirectoryError, InvalidDicomError):
Expand Down
15 changes: 13 additions & 2 deletions dcmrtstruct2nii/facade/dcmrtstruct2nii.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ def dcmrtstruct2nii(rtstruct_file, dicom_file, output_path, structures=None, gzi
rtreader = RtStructInputAdapter()

rtstructs = rtreader.ingest(rtstruct_file, maskname_pattern=maskname_pattern)
dicom_image = DcmInputAdapter().ingest(dicom_file, series_id=series_id)
# ``slice_z_positions`` is the per-DICOM ImagePositionPatient[2] array
# in the order ITK stacked the series. Passed through to the rasterizer
# so contour Z's are resolved to slice indices via nearest-IPP rather
# than SimpleITK's averaged ``spacing[2]`` -- which on non-uniform-Z
# series mis-routes contours on the irregular side of the gap.
dicom_image, slice_z_positions = DcmInputAdapter().ingest(
dicom_file, series_id=series_id, return_slice_z_positions=True,
)

dcm_patient_coords_to_mask = DcmPatientCoords2Mask()
nii_output_adapter = NiiOutputAdapter()
Expand All @@ -96,7 +103,11 @@ def dcmrtstruct2nii(rtstruct_file, dicom_file, output_path, structures=None, gzi
maskname = rtstruct['maskname']
logging.info(f'Working on mask {maskname}')
try:
mask = dcm_patient_coords_to_mask.convert(rtstruct['sequence'], dicom_image, mask_background_value, mask_foreground_value)
mask = dcm_patient_coords_to_mask.convert(
rtstruct['sequence'], dicom_image,
mask_background_value, mask_foreground_value,
slice_z_positions=slice_z_positions,
)
except ContourOutOfBoundsException:
logging.warning(f'Structure {maskname} is out of bounds, ignoring contour!')
continue
Expand Down
13 changes: 13 additions & 0 deletions dcmrtstruct2nii/tests/conformance.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Conformance threshold overrides for dcmrtstruct2nii vs rtmask-conformance defaults.
#
# Empty on purpose. After the first CI run, if a primitive lands just under
# the published default for understandable reasons (e.g. cube volume_rel_err
# from boundary-inclusive rasterization in scikit-image's polygon()), document
# the relaxation here with a date, the metric, and the path back to the
# published default. See the header of DicomRTTool's tests/conformance.yaml
# (https://github.com/brianmanderson/Dicom_RT_and_Images_to_Mask/blob/main/tests/conformance.yaml)
# for the canonical example.
#
# Per-primitive overrides shallow-merge over `defaults`, which themselves
# shallow-merge over the package-shipped defaults.
schema_version: 1
99 changes: 99 additions & 0 deletions dcmrtstruct2nii/tests/test_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Conformance test: dcmrtstruct2nii vs RTMaskConformanceTest analytic ground truth.

Runs only when the `conformance` extra is installed:

pip install -e .[conformance]
pytest dcmrtstruct2nii/tests/test_conformance.py -v

Without the extra, the module is skipped (importorskip), so default test
runs are unaffected.
"""

from __future__ import annotations

import os
from pathlib import Path

import pytest

# If `rtmask_conformance` isn't installed, skip the whole module so the
# default `pytest -vv -s` run continues to pass on CI without the extra.
rtmask_conformance = pytest.importorskip( # noqa: F841
"rtmask_conformance",
reason="install the `conformance` extra: pip install -e .[conformance]",
)

from rtmask_conformance import CONFORMANCE_ROIS, generate_fixture, load_config # noqa: E402
from rtmask_conformance.generate import GenerateOptions # noqa: E402
from rtmask_conformance.verify import Status, evaluate_one # noqa: E402

from dcmrtstruct2nii import dcmrtstruct2nii # noqa: E402

_CONFIG_YAML = Path(__file__).with_name("conformance.yaml")


@pytest.fixture(scope="session")
def conformance_fixture(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Generate the synthetic CT + RTSTRUCT + analytic GT NIfTIs once per session.

``n_quadrature=2`` keeps the fixture build under ~30 s; the published
default of 8 is overkill for a CI gate.
"""
out = tmp_path_factory.mktemp("conformance_fixture")
generate_fixture(out, options=GenerateOptions(n_quadrature=2))
return out


@pytest.fixture(scope="session")
def predictions(
conformance_fixture: Path, tmp_path_factory: pytest.TempPathFactory
) -> Path:
"""Run dcmrtstruct2nii against the conformance fixture once per session.

dcmrtstruct2nii writes ``mask_<roi>.nii.gz`` per ROI; the rtmask
verifier expects ``<roi>.nii.gz``. We rename in place after conversion
rather than asking the verifier to glob, because the rename is cheap
and keeps the rtmask side of the contract narrow.
"""
pred_dir = tmp_path_factory.mktemp("preds")

dcmrtstruct2nii(
rtstruct_file=str(conformance_fixture / "rtstruct" / "primitives_planar.dcm"),
dicom_file=str(conformance_fixture / "refct"),
output_path=str(pred_dir),
structures=None, # convert every ROI in the RTSTRUCT
gzip=True,
convert_original_dicom=False, # skip image.nii.gz; the gate doesn't use it
maskname_pattern=["ROIName"], # filenames: mask_<roi>.nii.gz (no ROINumber)
)

for src in pred_dir.glob("mask_*.nii.gz"):
roi = src.name[len("mask_"):]
src.rename(pred_dir / roi)

return pred_dir


@pytest.fixture(scope="session")
def conformance_config():
"""Resolve thresholds: env var > tests/conformance.yaml > package defaults."""
config_path = os.environ.get("RTMASK_CONFORMANCE_CONFIG")
if config_path is None and _CONFIG_YAML.is_file():
config_path = str(_CONFIG_YAML)
return load_config(config_path)


@pytest.mark.parametrize("roi", CONFORMANCE_ROIS)
def test_conformance(
roi: str, conformance_fixture: Path, predictions: Path, conformance_config
) -> None:
pred = predictions / f"{roi}.nii.gz"
gt = conformance_fixture / "groundtruth" / f"{roi}.nii.gz"
result = evaluate_one(roi, pred, gt, conformance_config)
if result.status != Status.PASS:
pytest.fail(
f"{roi}: {result.status.value}\n"
f" violations: {result.violations}\n"
f" metrics: {result.metrics}\n"
f" thresholds: {result.thresholds}"
)
2 changes: 2 additions & 0 deletions dcmrtstruct2nii/tests/test_dataset_bmia.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from dcmrtstruct2nii.tests.utils import compare_mask
from pathlib import Path
from dcmrtstruct2nii import dcmrtstruct2nii
import pytest
import shutil
import json
import warnings
Expand Down Expand Up @@ -102,6 +103,7 @@ def _cmp_left_right(left, right, key, cmpfunc):
return results


@pytest.mark.network
def test_bmia_stwstrategyhn1(tmpdir):
samples = gen_compare_list(tmpdir)

Expand Down
Loading