diff --git a/.github/workflows/build_and_test_cli.yml b/.github/workflows/build_and_test_cli.yml index 9c29120..a8ec1bf 100644 --- a/.github/workflows/build_and_test_cli.yml +++ b/.github/workflows/build_and_test_cli.yml @@ -1,13 +1,13 @@ -# Workflow to build nii2dcm and test different command line interface (CLI) options +# Workflow to build nii2dcm, run unit tests and then execute command line interface (CLI) end-to-end -name: Build nii2dcm +name: Build & Test nii2dcm on: pull_request: jobs: - build-and-test: - name: Build + venv-build-and-test: + name: venv + E2E runs-on: ${{ matrix.os }} @@ -57,6 +57,10 @@ jobs: nii2dcm -h nii2dcm -v + - name: Run unit tests + run: | + pytest tests/ + - name: Test DicomMRISVR creation run: | # run nii2dcm @@ -65,3 +69,52 @@ jobs: ls ./output # assert DICOM files exist [ -f "./output/IM_0001.dcm" ] && echo "Output DICOM file exists" || exit 1 + + - name: Build pytest coverage file + run: | + pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=nii2dcm tests/ | tee pytest-coverage.txt ; echo $? + + - name: Pytest coverage comment + id: coverageComment + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: ./pytest-coverage.txt + junitxml-path: ./pytest.xml + + - name: Update Coverage Badge + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.PYTEST_COVERAGE_COMMENT }} + gistID: 57ef8057d04f67dbe6e64df410b83079 + filename: nii2dcm-pytest-coverage-comment.json + label: Coverage Report + message: ${{ steps.coverageComment.outputs.coverage }} + color: ${{ steps.coverageComment.outputs.color }} + namedLogo: python + + container-build-and-test: + name: Container + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + python-version: [ '3.9' ] + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Build container + run: | + docker build -t nii2dcm --progress=plain --no-cache . + docker ps + + - name: Test nii2dcm container + run: | + docker run nii2dcm -h + echo "nii2dcm version:" + docker run nii2dcm -v diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 6f37c80..5ca0459 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -27,7 +27,7 @@ permissions: actions: write jobs: - testpypi-publish: + pypi-publish: name: Publish to PyPI runs-on: ubuntu-latest @@ -70,7 +70,7 @@ jobs: - name: Create dist/ run: | - python setup.py sdist bdist_wheel + python setup.py bdist_wheel twine check dist/* - name: Publish package to PyPI @@ -86,6 +86,7 @@ jobs: time: '150' # seconds - name: Install latest PyPI version in fresh venv + id: attempt1 run: | NII2DCM_VERSION=`echo "$(nii2dcm -v)"` echo $NII2DCM_VERSION @@ -97,3 +98,58 @@ jobs: nii2dcm -h echo "nii2dcm version:" nii2dcm -v + continue-on-error: true + + - name: Wait longer + if: steps.attempt1.outcome != 'success' + uses: GuillaumeFalourd/wait-sleep-action@v1 + with: + time: '150' # seconds + + - name: Re-attempt PyPI install + if: steps.attempt1.outcome != 'success' + run: | + NII2DCM_VERSION=`echo "$(nii2dcm -v)"` + echo $NII2DCM_VERSION + python -m venv nii2dcm-temp + source nii2dcm-temp/bin/activate + pip install --upgrade pip + pip install setuptools wheel + pip install nii2dcm==$NII2DCM_VERSION + nii2dcm -h + echo "nii2dcm version:" + nii2dcm -v + + ghcr-publish: + needs: pypi-publish + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME }} + password: ${{ secrets.GHCR_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/tomaroberts/nii2dcm + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/publish_testpypi.yml b/.github/workflows/publish_testpypi.yml index 6cafc3e..9a250d7 100644 --- a/.github/workflows/publish_testpypi.yml +++ b/.github/workflows/publish_testpypi.yml @@ -12,10 +12,12 @@ # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. +# name: Publish package to TestPyPI on: + pull_request: push: branches: - main @@ -67,7 +69,7 @@ jobs: - name: Create dist/ run: | - python setup.py sdist bdist_wheel + python setup.py bdist_wheel twine check dist/* - name: Publish package to TestPyPI @@ -84,6 +86,28 @@ jobs: time: '150' # seconds - name: Install latest TestPyPI version in fresh venv + id: attempt1 + run: | + NII2DCM_VERSION=`echo "$(nii2dcm -v)"` + echo $NII2DCM_VERSION + python -m venv nii2dcm-temp + source nii2dcm-temp/bin/activate + pip install --upgrade pip + pip install setuptools wheel + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ nii2dcm==$NII2DCM_VERSION + nii2dcm -h + echo "nii2dcm version:" + nii2dcm -v + continue-on-error: true + + - name: Wait longer + if: steps.attempt1.outcome != 'success' + uses: GuillaumeFalourd/wait-sleep-action@v1 + with: + time: '150' # seconds + + - name: Re-attempt TestPyPI install + if: steps.attempt1.outcome != 'success' run: | NII2DCM_VERSION=`echo "$(nii2dcm -v)"` echo $NII2DCM_VERSION diff --git a/.gitignore b/.gitignore index d7c3b9c..8234174 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +pytest-coverage.txt +pytest.xml # Sphinx documentation docs/_build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7bd4b02 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Use the official Python image as the base image +FROM python:3.9-slim + +LABEL org.opencontainers.image.source https://github.com/tomaroberts/nii2dcm + +# Setup +COPY . /home/nii2dcm +WORKDIR /home/nii2dcm + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + bash git \ + && apt-get clean + +# Update base packages +RUN pip install --upgrade pip && \ + pip install setuptools wheel + +# Install nii2dcm requirements +RUN pip install -r requirements.txt + +# Build package from source +RUN pip install . + +# Test nii2dcm install +# To see output locally during build process: docker build -t nii2dcm --progress=plain . +RUN nii2dcm -h + +ENTRYPOINT ["nii2dcm"] \ No newline at end of file diff --git a/README.md b/README.md index 7894e37..3e6cade 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ · Request Feature

+

+ + +

@@ -53,6 +57,7 @@ To install and run nii2dcm locally, you have two options: ### pip +Create a new Python virtual environment, then: ```shell pip install nii2dcm ``` @@ -74,7 +79,6 @@ python -m pip install --upgrade pip Install dependencies and nii2dcm: ```sh -pip install setuptools wheel pip install -r requirements.txt pip install . ``` @@ -138,6 +142,26 @@ Currently, attributes to transfer are [listed here in the DicomMRI class](https:

(back to top)

+ +## Docker +nii2dcm is also available as a Docker container. + +Pull the latest container with: +```shell +docker pull ghcr.io/tomaroberts/nii2dcm/nii2dcm:latest +``` + +Run the containerised nii2dcm: +```shell +# display nii2dcm version +docker run nii2dcm -v + +# perform nii2dcm conversion +docker run nii2dcm nifti-file.nii.gz dicom-output-directory/ -d MR +``` + +

(back to top)

+ ## Roadmap diff --git a/nii2dcm/__main__.py b/nii2dcm/__main__.py index de57792..f3ff34c 100644 --- a/nii2dcm/__main__.py +++ b/nii2dcm/__main__.py @@ -23,7 +23,11 @@ def cli(args=None): parser.add_argument("input_file", type=str, help="[.nii/.nii.gz] input NIfTI file") parser.add_argument("output_dir", type=str, help="[directory] output DICOM path") - parser.add_argument("-d", "--dicom_type", type=str, help="[string] type of DICOM. e.g. MR, CT, US, XR, etc.") + parser.add_argument( + "-d", "--dicom_type", + type=str, + help="[string] type of DICOM. Available types: MR, SVR." + ) parser.add_argument("-r", "--ref_dicom", type=str, help="[.dcm] Reference DICOM file for Attribute transfer") parser.add_argument("-v", "--version", action="version", version=__version__) diff --git a/nii2dcm/_version.py b/nii2dcm/_version.py index b8bebde..2a05741 100644 --- a/nii2dcm/_version.py +++ b/nii2dcm/_version.py @@ -1,2 +1,2 @@ -from dunamai import Version, Style -__version__ = Version.from_git().serialize(metadata=False, style=Style.SemVer) +import dunamai as _dunamai +__version__ = _dunamai.get_version("nii2dcm", third_choice=_dunamai.Version.from_any_vcs).serialize() diff --git a/nii2dcm/dcm.py b/nii2dcm/dcm.py index 7421db0..a7c537a 100644 --- a/nii2dcm/dcm.py +++ b/nii2dcm/dcm.py @@ -11,7 +11,6 @@ import pydicom as pyd from pydicom.dataset import FileDataset, FileMetaDataset -from nii2dcm.utils import dcm_dictionary_update from nii2dcm.modules.patient import Patient from nii2dcm.modules.general_study import GeneralStudy from nii2dcm.modules.patient_study import PatientStudy @@ -67,7 +66,7 @@ def __init__(self, filename=nii2dcm_temp_filename): """ Set Dicom Date/Time - Important: doing this once sets all Instances/Series/Study creation dates and times to the same values. Whereas, + Important: doing this once sets all Instances/Series/Study creation dates and times to the same values. Whereas, doing this within the Modules would every so slightly offset the times """ # TODO shift to utils.py and propagate to Modules, or, create method within this Dicom class diff --git a/nii2dcm/dcm_writer.py b/nii2dcm/dcm_writer.py index 4689376..f1291a8 100644 --- a/nii2dcm/dcm_writer.py +++ b/nii2dcm/dcm_writer.py @@ -6,19 +6,19 @@ import pydicom as pyd -def write_slice(dcm, img_data, instance_index, output_dir): +def write_slice(dcm, img_data, slice_index, output_dir): """ write a single DICOM slice dcm – nii2dcm DICOM object img_data - [nX, nY, nSlice] image pixel data, such as from NIfTI file - instance_index – instance index (important: counts from 0) + slice_index – slice index in nibabel img_data array (important: counts from 0, whereas DICOM instances count from 1) output_dir – output DICOM file save location """ - output_filename = r'IM_%04d.dcm' % (instance_index + 1) # begin filename from 1, e.g. IM_0001.dcm + output_filename = r'IM_%04d.dcm' % (slice_index + 1) # begin filename from 1, e.g. IM_0001.dcm - img_slice = img_data[:, :, instance_index] + img_slice = img_data[:, :, slice_index] # Instance UID – unique to current slice dcm.ds.SOPInstanceUID = pyd.uid.generate_uid(None) diff --git a/nii2dcm/modules/mr_image.py b/nii2dcm/modules/mr_image.py index 7f146da..a460ca7 100644 --- a/nii2dcm/modules/mr_image.py +++ b/nii2dcm/modules/mr_image.py @@ -24,9 +24,9 @@ def __init__(self): # https://dicom.nema.org/medical/Dicom/current/output/chtml/part03/sect_C.8.3.html#sect_C.8.3.1.1.1 # For now, will omit thereby inheriting parent value # self.ds.ImageType = '' - + self.ds.SamplesPerPixel = 1 - + # PhotometricInterpretation # TODO: decide MONOCHROME1 or MONOCHROME2 as default # https://dicom.nema.org/medical/Dicom/current/output/chtml/part03/sect_C.7.6.3.html#sect_C.7.6.3.1.2 diff --git a/requirements.txt b/requirements.txt index 91edd24..d7e88bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +setuptools +wheel numpy==1.23.2 matplotlib==3.6.2 nibabel==5.0.0 diff --git a/setup.py b/setup.py index ce87831..9f8c9e7 100644 --- a/setup.py +++ b/setup.py @@ -3,5 +3,5 @@ setup( name="nii2dcm", - version=Version.from_git().serialize(metadata=False, style=Style.SemVer), + version=Version.from_any_vcs().serialize(metadata=False, style=Style.SemVer), ) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dcm.py b/tests/test_dcm.py new file mode 100644 index 0000000..046dd0f --- /dev/null +++ b/tests/test_dcm.py @@ -0,0 +1,82 @@ +import pytest +import os +import datetime + +from pydicom.dataset import FileMetaDataset, FileDataset + +from nii2dcm.dcm import Dicom +from nii2dcm.modules.patient import Patient + + +TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1' +DATE = datetime.datetime.now().strftime('%Y%m%d') +PATIENT_ID = '12345678' +PATIENT_SEX = '' +IMAGE_TYPE = ['SECONDARY', 'DERIVED'] +CHARACTER_SET = 'ISO_IR 100' + +MIN_UID_LENGTH = 10 # arbitrary just to check UID has some characters +MAX_UID_LENGTH = 64 # DICOM standard max length + +class TestDicom: + def setup_method(self): + self.dicom = Dicom() + + def test_dicom(self): + """ + Tests some metadata in basic Dicom object + """ + assert self.dicom.file_meta.TransferSyntaxUID == TRANSFER_SYNTAX_UID + assert self.dicom.ds.ContentDate == DATE + assert self.dicom.ds.AcquisitionDate == DATE + assert self.dicom.ds.SeriesDate == DATE + assert self.dicom.ds.StudyDate == DATE + + def test_add_module(self): + """ + Tests add_module() method + """ + self.dicom.add_module(Patient()) + assert self.dicom.ds.PatientID == PATIENT_ID + assert self.dicom.ds.PatientSex == PATIENT_SEX + + def test_add_base_modules(self): + """ + Test metadata present following bulk method invocation via add_base_modules() + """ + self.dicom.add_base_modules() + assert self.dicom.ds.SpecificCharacterSet == CHARACTER_SET + assert self.dicom.ds.ImageType[0] == 'SECONDARY' + assert self.dicom.ds.ImageType[1] == 'DERIVED' + + def test_get_file_meta(self): + fm = self.dicom.get_file_meta() + assert isinstance(fm, FileMetaDataset) + + def test_get_dataset(self): + ds = self.dicom.get_dataset() + assert isinstance(ds, FileDataset) + + def test_save_as(self): + """ + Test DICOM save (default save location: cwd) + """ + self.dicom.ds.save_as(self.dicom.filename) + assert os.path.exists(self.dicom.filename) + os.remove(self.dicom.filename) + if os.path.exists(self.dicom.filename): + raise Exception("Failed to delete temporary DICOM created during pytest process.") + + def test_init_study_tags(self): + self.dicom.init_study_tags() + assert isinstance(self.dicom.ds.StudyInstanceUID, str) + assert self.dicom.ds.StudyInstanceUID.find(".") + assert MIN_UID_LENGTH < len(self.dicom.ds.StudyInstanceUID) <= MAX_UID_LENGTH + + def test_init_series_tags(self): + self.dicom.init_study_tags() + assert isinstance(self.dicom.ds.SeriesInstanceUID, str) + assert self.dicom.ds.SeriesInstanceUID.find(".") + assert MIN_UID_LENGTH < len(self.dicom.ds.SeriesInstanceUID) <= MAX_UID_LENGTH + + assert len(str(self.dicom.ds.SeriesNumber)) == 4 diff --git a/tests/test_dcm_writer.py b/tests/test_dcm_writer.py new file mode 100644 index 0000000..2b460a0 --- /dev/null +++ b/tests/test_dcm_writer.py @@ -0,0 +1,46 @@ +import os + +import pytest +import nibabel as nib + +from nii2dcm import dcm_writer +from nii2dcm.dcm import Dicom +from nii2dcm.nii import Nifti + + +NII_FILE_PATH = "tests/data/DicomMRISVR/t2-svr-atlas-35wk.nii.gz" +INSTANCE_INDEX = 10 # dcm instances count from 1 +SLICE_NUMBER = INSTANCE_INDEX-1 # nibabel slice array counts from 0 +OUTPUT_DIR = "tests/data" +OUTPUT_DCM_FILENAME = r'IM_%04d.dcm' % (INSTANCE_INDEX) +OUTPUT_DCM_PATH = os.path.join(os.getcwd(), OUTPUT_DIR, OUTPUT_DCM_FILENAME) + +class TestDicomWriter: + def setup_method(self): + self.dicom = Dicom() + self.nii = nib.load(NII_FILE_PATH) + self.img_data = self.nii.get_fdata().astype("uint16") + self.nii2dcm_parameters = Nifti.get_nii2dcm_parameters(self.nii) + + def test_write_slice(self): + dcm_writer.write_slice(self.dicom, self.img_data, SLICE_NUMBER, OUTPUT_DIR) + + assert os.path.exists(OUTPUT_DCM_PATH) + os.remove(OUTPUT_DCM_PATH) + if os.path.exists(OUTPUT_DCM_PATH): + raise Exception("Failed to delete temporary DICOM created during pytest process.") + + def test_transfer_nii_hdr_series_tags(self): + dcm_writer.transfer_nii_hdr_series_tags(self.dicom, self.nii2dcm_parameters) + assert self.dicom.ds.Rows == self.nii.shape[0] + assert self.dicom.ds.Columns == self.nii.shape[1] + + def test_transfer_nii_hdr_instance_tags(self): + dcm_writer.transfer_nii_hdr_instance_tags(self.dicom, self.nii2dcm_parameters, SLICE_NUMBER) + assert self.dicom.ds.InstanceNumber == INSTANCE_INDEX + + def test_transfer_ref_dicom_series_tags(self): + """ + TODO: implement test + """ + pass diff --git a/tests/test_nii.py b/tests/test_nii.py new file mode 100644 index 0000000..d59e4a0 --- /dev/null +++ b/tests/test_nii.py @@ -0,0 +1,23 @@ +import pytest + +from nii2dcm.nii import Nifti +import nibabel as nib + + +NII_FILE_PATH = "tests/data/DicomMRISVR/t2-svr-atlas-35wk.nii.gz" +NII_VOXEL_DIMS = (180, 221, 180) +NII_VOXEL_SPACING = (0.5, 0.5, 0.5) + +class TestNifti: + def setup_method(self): + self.nii = nib.load(NII_FILE_PATH) + + def test_get_nii2dcm_parameters(self): + nii_parameters = Nifti.get_nii2dcm_parameters(self.nii) + assert nii_parameters["Rows"] == NII_VOXEL_DIMS[0] + assert nii_parameters["Columns"] == NII_VOXEL_DIMS[1] + assert nii_parameters["NumberOfSlices"] == NII_VOXEL_DIMS[2] + assert nii_parameters["AcquisitionMatrix"] == [0, NII_VOXEL_DIMS[0], NII_VOXEL_DIMS[1], 0] + assert nii_parameters["dimX"] == NII_VOXEL_SPACING[0] + assert nii_parameters["dimY"] == NII_VOXEL_SPACING[1] + assert nii_parameters["SliceThickness"] == str(NII_VOXEL_SPACING[2]) diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..8c95343 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,55 @@ +import pytest +import os, shutil +import pydicom as pyd + +from nii2dcm.run import run_nii2dcm + + +NII_FILE_PATH = "tests/data/DicomMRISVR/t2-svr-atlas-35wk.nii.gz" +OUTPUT_DIR = "tests/data/tmp_dcm_dir" +OUTPUT_DCM_PATH = os.path.join(os.getcwd(), OUTPUT_DIR) +NUM_DICOM_FILES = 180 +SINGLE_DICOM_FILENAME = "IM_0001.dcm" + +class TestRun: + def setup_method(self): + os.makedirs(OUTPUT_DCM_PATH, exist_ok=True) + + @pytest.mark.parametrize( + "TEST_DICOM_TYPE, TEST_DCM_MODALITY", + [ + (None, ''), # basic DICOM with undefined modality + ("MR", "MR"), # MRI DICOM + ("SVR", "MR") # SVR DICOM hence MR modality + ] + ) + def test_run_dicom_types(self, TEST_DICOM_TYPE, TEST_DCM_MODALITY): + """ + Test run_nii2dcm with different dicom_types + """ + run_nii2dcm( + NII_FILE_PATH, + OUTPUT_DCM_PATH, + dicom_type=TEST_DICOM_TYPE + ) + assert os.path.exists(os.path.join(OUTPUT_DCM_PATH, SINGLE_DICOM_FILENAME)) + assert len(os.listdir(OUTPUT_DCM_PATH)) == NUM_DICOM_FILES + + ds = pyd.dcmread(os.path.join(OUTPUT_DCM_PATH, SINGLE_DICOM_FILENAME)) + assert ds.Modality == TEST_DCM_MODALITY + + shutil.rmtree(OUTPUT_DCM_PATH) + + def test_run_reference_dicom(self): + """ + Test run_nii2dcm with different ref_dicom option + """ + # TODO: implement - will involve adding reference DICOM test dataset + pass + + def teardown_method(self): + """ + Remove output DICOM directory in event of test failure + """ + if os.path.exists(OUTPUT_DCM_PATH): + shutil.rmtree(OUTPUT_DCM_PATH) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..caf55d2 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,9 @@ +import pytest +from packaging import version + +from nii2dcm._version import __version__ + + +class TestVersion: + def test_version(self): + assert isinstance(version.parse(__version__), version.Version)