diff --git a/.github/workflows/docker-build-and-run.yml b/.github/workflows/docker-build-and-run.yml index e925c74..e7b12a5 100644 --- a/.github/workflows/docker-build-and-run.yml +++ b/.github/workflows/docker-build-and-run.yml @@ -76,3 +76,73 @@ jobs: - name: Cleanup Docker run: | docker system prune -a --force + build-dicom2niix-and-run-docker: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Generate input files + run: | + python -m Docker.generate_signal_docker_test --dicom + + - name: Verify input files + run: | + IVIM_SIM_DIR="${{ github.workspace }}/ivim_simulation" + for file in ivim_simulation_0000.dcm ivim_simulation_0001.dcm ivim_simulation_0002.dcm ivim_simulation_0003.dcm ivim_simulation_0004.dcm; do + if [ ! -f "$IVIM_SIM_DIR/$file" ]; then + echo "Error: $IVIM_SIM_DIR/$file not found" + exit 1 + fi + done + echo "All input files generated successfully" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + run: | + docker build -t tf2.4_ivim-mri_codecollection -f Docker/dicom2nifti/Dockerfile . + + - name: Run Docker container + run: | + docker run --rm --name TF2.4_IVIM-MRI_CodeCollection \ + -v ${{ github.workspace }}:/usr/src/app \ + tf2.4_ivim-mri_codecollection \ + /usr/src/app/ivim_simulation + + - name: Verify output files + run: | + for file in f.nii.gz dp.nii.gz d.nii.gz; do + if [ ! -f "$file" ]; then + echo "Error: $file not found" + exit 1 + fi + done + echo "All output files generated successfully" + + - name: Clean up artifacts and Docker image + run: | + docker rmi tf2.4_ivim-mri_codecollection || true + rm -f tf2.4_ivim-mri_codecollection.tar.gz + rm -f ${{ github.workspace }}/f.nii.gz + rm -f ${{ github.workspace }}/dp.nii.gz + rm -f ${{ github.workspace }}/d.nii.gz + rm -f ${{ github.workspace }}/ivim_simulation.nii.gz + rm -f ${{ github.workspace }}/ivim_simulation.bval + rm -f ${{ github.workspace }}/ivim_simulation.bvec + rm -r -f ${{ github.workspace }}/ivim_simulation || true + - name: Cleanup Docker + run: | + docker system prune -a --force + diff --git a/Docker/Dockerfile b/Docker/Dockerfile index a5537f9..2f7c694 100644 --- a/Docker/Dockerfile +++ b/Docker/Dockerfile @@ -14,4 +14,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY .. . -ENTRYPOINT ["python3", "-m", "WrapImage.nifti_wrapper"] +ENTRYPOINT ["python3", "-m", "WrapImage.nifti_wrapper"] \ No newline at end of file diff --git a/Docker/README.md b/Docker/README.md index 32f5305..c8f6dd5 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -4,18 +4,21 @@ This project is designed to run the `nifti_wrapper` script using a Docker contai ## Prerequisites -- Docker must be installed on your system. +- Docker must be installed on your system. ## Directory Structure -``` +``` sh ~/TF2.4_IVIM-MRI_CodeCollection/ │ ├── Docker/ │ └── Dockerfile +│ └── dicom2nifti/ +│ └── Dockerfile │ ├── WrapImage/ │ └── nifti_wrapper.py +│ └── dicom2niix_wrapper.py │ └── requirements.txt ``` @@ -45,6 +48,12 @@ Before running the Docker container, here are the available options for the `Doc sudo docker build -t tf2.4_ivim-mri_codecollection -f Docker/Dockerfile . ``` + OR (If you need to convert your data from DICOM TO NIfTI images) + + ```sh + sudo docker build -t tf2.4_ivim-mri_codecollection -f Docker/dicom2nifti/Dockerfile . + ``` + ## Running the Docker Container 1. Once the image is built, you can run the Docker container using the `docker run` command. This command runs the Docker image with the specified input files: @@ -58,4 +67,64 @@ Before running the Docker container, here are the available options for the `Doc Replace `brain.nii.gz`, `brain.bvec`, and `brain.bval` with the actual file names you want to use. +## Running the Docker container for reading in DICOM Images + +- You can run the dicom2nifti Docker container using the `docker run` command. This command runs the Docker image with the specified input files: + + ```sh + sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \ + -v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \ + tf2.4_ivim-mri_codecollection \ + /usr/src/app/dicom_folder + ``` + +- You can run the dicom2niix_wrapper.py script either directly or from inside a Docker container (non-interactive only). Here are the available options: + +## Options +Before running the Docker container, here are the available options for the `Docker image` script: + +- `input`: Path to the input DICOM directory. Some scanners store images in nested subfolders, so a single session might be stored in the folders "/usr/subj22/111", "/usr/subj22/112" and "/usr/subj22/123". In this case you should simply provide the parent directly ("/usr/subj22") and dcm2niix will recursively search the sub directories and organize all your images for you. +- `output`: Path to the output directory for the converted NIfTI files and sidecar BIDS JSON. +- ` -m, --merge-2d`: Merge 2D slices into a 3D or 4D NIfTI image regardless of study time, echo, coil, orientation, etc. Depending on your vendor, you may want to keep images segmented based on these attributes or merge/combine them. +- ` -s, --single-file`: Use single file mode (convert only one series per folder). For example, if the input path "~/dir/001.dcm" will only convert the file 001.dcm. +- ` -pu, --prompt-user`: Run the tool in interactive mode. This launches a terminal-based wizard where you can select DICOM folders and configure conversion interactively. + + + +### Example usage + +```sh +sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \ + -v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \ + -v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \ + tf2.4_ivim-mri_codecollection \ + /usr/src/app/dicom_folder -o /usr/app/output +``` + +```sh +sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \ + -v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \ + -v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \ + tf2.4_ivim-mri_codecollection \ + /usr/src/app/dicom_folder -o /usr/app/output -m +``` + +```sh +sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \ + -v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \ + -v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \ + tf2.4_ivim-mri_codecollection \ + /usr/src/app/dicom_file -o /usr/app/output -s +``` + +[Note that NIfTI and DICOM encode space differently](https://www.nitrc.org/plugins/mwiki/index.php/dcm2nii:MainPage#Spatial_Coordinates) + +![image](https://www.nitrc.org/plugins/mwiki/images/thumb/8/8e/Dcm2nii%3AMni_v_dicom.jpg/300px-Dcm2nii%3AMni_v_dicom.jpg) + +##### The goal of dcm2niix is to create FSL format bvec/bval files for processing. A crucial concern is ensuring that the gradient directions are reported in the frame of reference expected by the software you use to fit your tractography. [dicom2niix should generate a ".bvec" file that reports the tensors as expected](https://www.nitrc.org/plugins/mwiki/index.php/dcm2nii:MainPage#Diffusion_Tensor_Imaging) by FSL's dtifit, where vectors are reported relative to image frame of reference (rather than relative to the scanner bore) + +#### It is strongly recommend that users check validate the b-vector directions for their hardware and sequence as [described in a dedicated document](https://www.nitrc.org/docman/?group_id=880) + +- NIfTI follows the Talairach/MNI coordinate system where the X value increases as we move toward the participant's right, the Y increases as we move anteriorly. In contrast, for bipeds DICOM specifies the the X increases as we more toward the participant's left, while the Y increases as we move posteriorly. Both agree that the Z coordinate increases as we move superiorly. + --- diff --git a/Docker/dicom2nifti/Dockerfile b/Docker/dicom2nifti/Dockerfile new file mode 100644 index 0000000..ab65963 --- /dev/null +++ b/Docker/dicom2nifti/Dockerfile @@ -0,0 +1,40 @@ +FROM debian:stable-slim AS build + +ARG DCM2NIIX_VERSION=v1.0.20241211 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + git \ + libssl-dev \ + wget \ + pigz \ + ca-certificates \ + && update-ca-certificates \ + && wget https://github.com/rordenlab/dcm2niix/archive/refs/tags/${DCM2NIIX_VERSION}.tar.gz -O /tmp/dcm2niix.tar.gz \ + && mkdir -p /tmp/dcm2niix && tar -xzf /tmp/dcm2niix.tar.gz -C /tmp/dcm2niix --strip-components=1 \ + && mkdir /tmp/dcm2niix/build && cd /tmp/dcm2niix/build \ + && cmake -DBATCH_VERSION=ON -DZLIB_IMPLEMENTATION=Cloudflare -DUSE_JPEGLS=ON -DUSE_OPENJPEG=ON .. \ + && make && make install \ + && rm -rf /tmp/dcm2niix* \ + && apt-get remove -y wget git cmake \ + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +FROM python:3.11-slim + +WORKDIR /usr/src/app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pigz \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /usr/local/bin/dcm2niix /usr/local/bin/dcm2niix + +COPY ../.. . + +RUN pip install --no-cache-dir . + +ENTRYPOINT ["python3", "-m", "WrapImage.dicom2niix_wrapper"] diff --git a/Docker/generate_signal_docker_test.py b/Docker/generate_signal_docker_test.py index c6c96a3..49707cf 100644 --- a/Docker/generate_signal_docker_test.py +++ b/Docker/generate_signal_docker_test.py @@ -1,9 +1,14 @@ +import datetime +import os +from pathlib import Path +import sys +import uuid import numpy as np import nibabel as nib +import itk from utilities.data_simulation.GenerateData import GenerateData from WrapImage.nifti_wrapper import save_nifti_file - def save_bval_bvec(filename, values): if filename.endswith('.bval'): # Convert list to a space-separated string for bval @@ -17,6 +22,8 @@ def save_bval_bvec(filename, values): with open(filename, 'w') as file: file.write(values_string) + + # Set random seed for reproducibility np.random.seed(42) # Create GenerateData instance @@ -40,3 +47,65 @@ def save_bval_bvec(filename, values): save_bval_bvec("ivim_simulation.bval", [0, 50, 100, 500, 1000]) # Save the bvec value save_bval_bvec("ivim_simulation.bvec", [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + + +def save_dicom_files(): + os.makedirs("ivim_simulation", exist_ok=True) + InputImageType = itk.Image[itk.D, 3] + ReaderType = itk.ImageFileReader[InputImageType] + NiftiImageIOType = itk.NiftiImageIO.New() + + + reader = ReaderType.New() + reader.SetImageIO(NiftiImageIOType) + reader.SetFileName("ivim_simulation.nii.gz") + + try: + reader.Update() + except Exception as e: + print(f"Error occured while reading NIfTI in ivim_simulation: {e}") + sys.exit(1) + + OutputPixelType = itk.SS + # The casting filter output image type will be 3D with the new pixel type + CastedImageType = itk.Image[OutputPixelType, 3] + CastFilterType = itk.CastImageFilter[InputImageType, CastedImageType] + caster = CastFilterType.New() + caster.SetInput(reader.GetOutput()) + caster.Update() + + + OutputImageType = itk.Image[OutputPixelType, 2] + FileWriterType = itk.ImageSeriesWriter[CastedImageType, OutputImageType] + GDCMImageIOType = itk.GDCMImageIO.New() + writer = FileWriterType.New() + size = reader.GetOutput().GetLargestPossibleRegion().GetSize() + fnames = itk.NumericSeriesFileNames.New() + num_slices = size[2] + + fnames.SetStartIndex(0) + fnames.SetEndIndex(num_slices - 1) # Iterate over the Z dimension (slices) + fnames.SetIncrementIndex(1) + fnames.SetSeriesFormat(os.path.join("ivim_simulation", f"ivim_simulation_%04d.dcm")) + + # meta_dict = itk.MetaDataDictionary() + # include correct headers here to be tuned for Vendor + # GDCMImageIOType.SetMetaDataDictionary(meta_dict) + # GDCMImageIOType.KeepOriginalUIDOn() + writer.SetInput(caster.GetOutput()) + writer.SetImageIO(GDCMImageIOType) + writer.SetFileNames(fnames.GetFileNames()) + try: + writer.Write() + except Exception as e: + print(f"Error occurred while writing DICOMs in ivim simulation: {e}") + sys.exit(1) + +args = sys.argv[1:] +if "--dicom" in args: + # read the generated nii file to dicom files + save_dicom_files() + # Save the bval in a file + save_bval_bvec(os.path.join("ivim_simulation","ivim_simulation.bval"), [0, 50, 100, 500, 1000]) + # Save the bvec value + save_bval_bvec(os.path.join("ivim_simulation","ivim_simulation.bvec"), [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) diff --git a/WrapImage/__init__.py b/WrapImage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/WrapImage/dicom2niix_wrapper.py b/WrapImage/dicom2niix_wrapper.py new file mode 100644 index 0000000..a789252 --- /dev/null +++ b/WrapImage/dicom2niix_wrapper.py @@ -0,0 +1,228 @@ +import argparse +import io +import os +from pathlib import Path +import selectors +import subprocess +import sys +import inquirer +import numpy as np + +def prompt_input_directory(): + return inquirer.prompt([ + inquirer.Path( + "path", + message="📂 Select an input directory containing DICOM image files:", + path_type=inquirer.Path.DIRECTORY, + exists=True, + ) + ])["path"] + + +def prompt_output_directory(input_dir): + # List subfolders of input_dir + subdirs = [ + name for name in os.listdir(input_dir) + if os.path.isdir(os.path.join(input_dir, name)) + ] + + choices = [f"./{name}" for name in subdirs] + choices.append("📥 Enter a custom output path...") + + answer = inquirer.prompt([ + inquirer.List( + "choice", + message=f"📁 Choose an output directory for NIfTI files from:\n → {input_dir}", + choices=choices + ) + ])["choice"] + + if answer == "📥 Enter a custom output path...": + return inquirer.prompt([ + inquirer.Path( + "custom_path", + message="📥 Enter custom output directory path:", + path_type=inquirer.Path.DIRECTORY, + exists=True + ) + ])["custom_path"] + else: + return os.path.abspath(os.path.join(input_dir, answer.strip("./"))) + + +def dicom_to_niix(vol_dir: Path, out_dir: Path = None, merge_2d: bool = False, is_single_file: bool = False): + """ + For converting DICOM images to a (compresssed) 4d nifti image + """ + if not out_dir: + os.makedirs(out_dir, exist_ok=True) + + cmd = [ + "dcm2niix", + "-f", "%s_%p", # dcm2niix attempts to provide a sensible file naming scheme + "-o", out_dir if out_dir else "", # Add merge option + "-z", "y", # parallel pigz compressed nii.gz file; 'optimal'"-z o" which pipes data directly to pigz + "-m", "y" if merge_2d else "n", # Add merge option + "-s", "y" if is_single_file else "n", + # https://www.nitrc.org/plugins/mwiki/index.php/dcm2nii:MainPage + # for further configuration for general usage see page above + vol_dir + ] + + try: + success, output = capture_subprocess_output(cmd) + print(output) + if not success: + raise RuntimeError(f"dcm2niix failed: {output}") + + nifti_files = list(Path(out_dir).glob("*.nii.gz")) + + if is_single_file and len(nifti_files) != 1: + raise RuntimeError("Expected a single .nii.gz output due to flags" + " Please collect your NIfTI files in the output directory prior to execution.") + if merge_2d and len(nifti_files) != 1: + raise RuntimeError("Expected a single .nii.gz output due to flags" + " Check the Warnings logged by dicom2niix to see source of error.") + if len(nifti_files) < 1: + raise RuntimeError("No NIfTI (.nii.gz) files were generated." + " Check the Warnings logged by dicom2niix and Double-check your input before running again.") + + bval_files = list(out_dir.glob("*.bval")) + bvec_files = list(out_dir.glob("*.bvec")) + bval_path = str(bval_files[0]) if bval_files else None + bvec_path = str(bvec_files[0]) if bvec_files else None + + if not bval_path or not bvec_path: + raise RuntimeError("No bvec or bval files were generated.") + + return nifti_files[0], bval_path, bvec_path + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"dcm2niix failed: {e.stderr}") + +### Adapted from https://gist.github.com/nawatts/e2cdca610463200c12eac2a14efc0bfb ### +### for further breakdown and see gist above ### +### for future improvements https://me.micahrl.com/blog/magicrun/ to be called over SSH OR SLURM. ### +def capture_subprocess_output(subprocess_args): + process = subprocess.Popen( + subprocess_args, + bufsize=1, # output is line buffered + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True # for line buffering + ) + + buf = io.StringIO() # callback for output + def handle_output(stream, mask): + line = stream.readline() + buf.write(line) + sys.stdout.write(line) + + selector = selectors.DefaultSelector() # register callback + selector.register(process.stdout, selectors.EVENT_READ, handle_output) # for 'read' event from subprocess stdout stream + + while process.poll() is None: + events = selector.select() + for key, mask in events: + callback = key.data + callback(key.fileobj, mask) + + # ensure all remaining output is processed + while True: + line = process.stdout.readline() + if not line: + break + buf.write(line) + sys.stdout.write(line) + + return_code = process.wait() + selector.close() + + success = (return_code == 0) + output = buf.getvalue() + buf.close() + + return success, output + +def run_interactive(): + + input_dirs = [] + output_dirs = [] + + while True: + input_dir = prompt_input_directory() + output_dir = prompt_output_directory(input_dir) + + input_dirs.append(input_dir) + output_dirs.append(output_dir) + + add_more = inquirer.prompt([ + inquirer.Confirm("more", message="➕ Add another input/output pair?", default=False) + ])["more"] + + if not add_more: + break + + merge_answer = inquirer.prompt([ + inquirer.Confirm("merge", message="🧩 Merge 2D slices into a single NIfTI (-m y)?", default=True) + ]) + merge_2d = merge_answer["merge"] + + single_file = inquirer.prompt([ + inquirer.Confirm("single", message="📦 Force single file input (-s y)?", default=False) + ])["single"] + + for in_dir, out_dir in zip(input_dirs, output_dirs): + vol_dir = Path(in_dir) + out_path = Path(out_dir) + + print(f"Converting:\n → Input: {vol_dir}\n → Output: {out_path}") + try: + nifti, bval, bvec = dicom_to_niix(vol_dir, out_path, merge_2d, single_file) + print(f" Conversion succeeded: {nifti}") + except RuntimeError as err: + print(f"❌ Conversion failed: {err}") + +def run_cli(input_path: str, output_path: str, **kwargs): + vol_dir = Path(input_path) + out_dir = Path(output_path) if output_path else vol_dir + + merge_2d = kwargs.get("merge_2d", False) + single_file = kwargs.get("single_file", False) + + print(f" Converting:\n → Input: {vol_dir}\n → Output: {out_dir}") + try: + nifti, bval, bvec = dicom_to_niix(vol_dir, out_dir, merge_2d, single_file) + print(f" Created NIfTI: {nifti}") + + if bval and bvec: + print(" Running IVIM fitting algorithm...") + subprocess.run([ + "python3", "-m", "WrapImage.nifti_wrapper", + str(nifti), str(bvec), str(bval) + ], check=True) + print(" IVIM fitting complete.") + else: + print("⚠️ bvec/bval missing, skipping IVIM post-processing.") + except RuntimeError as err: + print(f"❌ Conversion failed: {err}") + sys.exit(1) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="DICOM to NIfTI converter with optional IVIM processing") + parser.add_argument("input", nargs="?", help="Path to input DICOM directory") + parser.add_argument("-o", "--output", nargs="?", help="Path to output directory for NIfTI files, defaults to the same folder as the original DICOM files.") + parser.add_argument("-m", "--merge-2d", action="store_true", help="Merge 2D slices (-m y)") + parser.add_argument("-s", "--single-file", action="store_true", help="Enable single file mode (-s y)") + parser.add_argument("-pu", "--prompt-user", action="store_true", help="Run in interactive mode") + + args = parser.parse_args() + + if args.prompt_user: + run_interactive() + elif args.input: + run_cli(args.input, args.output, **vars(args)) + else: + print("❗ You must provide input and output paths OR use --prompt-user for interactive mode.") + parser.print_help() + sys.exit(1) \ No newline at end of file diff --git a/WrapImage/nifti_wrapper.py b/WrapImage/nifti_wrapper.py index 2117e57..7ffd38c 100644 --- a/WrapImage/nifti_wrapper.py +++ b/WrapImage/nifti_wrapper.py @@ -1,12 +1,12 @@ import argparse import json import os +from pathlib import Path import nibabel as nib from src.wrappers.OsipiBase import OsipiBase import numpy as np from tqdm import tqdm - def read_nifti_file(input_file): """ For reading the 4d nifti image @@ -81,9 +81,9 @@ def loop_over_first_n_minus_1_dimensions(arr): if __name__ == "__main__": parser = argparse.ArgumentParser(description="Read a 4D NIfTI phantom file along with BIDS JSON, b-vector, and b-value files.") - parser.add_argument("input_file", type=str, help="Path to the input 4D NIfTI file.") - parser.add_argument("bvec_file", type=str, help="Path to the b-vector file.") - parser.add_argument("bval_file", type=str, help="Path to the b-value file.") + parser.add_argument("input_file", type=Path, help="Path to the input 4D NIfTI file") + parser.add_argument("bvec_file", type=Path, help="Path to the b-vector file.") + parser.add_argument("bval_file", type=Path, help="Path to the b-value file.") parser.add_argument("--affine", type=float, nargs="+", help="Affine matrix for NIfTI image.") parser.add_argument("--algorithm", type=str, default="OJ_GU_seg", help="Select the algorithm to use.") parser.add_argument("--algorithm_args", nargs=argparse.REMAINDER, help="Additional arguments for the algorithm.") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d694bcc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "TF2.4_IVIM-MRI-CodeCollection" +version = "0.1.0" +description = "IVIM MRI signal processing and NIfTI/DICOM processing tools" +requires-python = ">=3.9" +readme = "README.md" +dependencies = [ + "numpy<2", + "nibabel", + "inquirer", + "scipy", + "torchio", + "torch", + "joblib", + "dipy", + "matplotlib", + "scienceplots", + "cvxpy", + "zenodo-get", + "tqdm", + "pandas", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-json-report", + "itk", +] +docs = [ + "sphinx", + "sphinx_rtd_theme", +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["src", "utilities", "WrapImage"] + +[tool.setuptools.package-dir] +"" = "." + diff --git a/requirements.txt b/requirements.txt index 29742e3..089388b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ numpy<2 nibabel +inquirer +itk scipy torchio torch