diff --git a/.github/workflows/build_and_test_cli.yml b/.github/workflows/build_and_test_cli.yml
index 9c29120..04ac3fb 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,53 @@ 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
+ default-branch: unit-tests
+
+ - 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 c201874..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
@@ -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 e8b22f6..9a250d7 100644
--- a/.github/workflows/publish_testpypi.yml
+++ b/.github/workflows/publish_testpypi.yml
@@ -12,6 +12,7 @@
# 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
@@ -85,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 68cc552..3e6cade 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,10 @@
·
Request Feature
+
+
+
+
@@ -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 abd393e..f3ff34c 100644
--- a/nii2dcm/__main__.py
+++ b/nii2dcm/__main__.py
@@ -24,7 +24,7 @@ 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",
+ "-d", "--dicom_type",
type=str,
help="[string] type of DICOM. Available types: MR, SVR."
)
diff --git a/nii2dcm/_version.py b/nii2dcm/_version.py
index 0699dbf..2a05741 100644
--- a/nii2dcm/_version.py
+++ b/nii2dcm/_version.py
@@ -1,2 +1,2 @@
import dunamai as _dunamai
-__version__ = _dunamai.get_version("nii2dcm", third_choice=_dunamai.Version.from_any_vcs).serialize()
\ No newline at end of file
+__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/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)