From d6bda279dc4cd6e1a6f03031cedd2b17c040ccae Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 09:46:34 -0400 Subject: [PATCH 01/18] Remove nipype dependencies and simplify code. Add GitHub Actions --- .github/workflows/build-push-docker.yml | 38 +++ .gitignore | 350 ++++++++++++++++++++++++ Dockerfile | 20 +- phase_unwrap.py | 310 +++++++-------------- requirements.txt | 2 + 5 files changed, 490 insertions(+), 230 deletions(-) create mode 100644 .github/workflows/build-push-docker.yml create mode 100644 .gitignore create mode 100644 requirements.txt diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml new file mode 100644 index 0000000..f0160aa --- /dev/null +++ b/.github/workflows/build-push-docker.yml @@ -0,0 +1,38 @@ +name: build-push-docker + +on: + push: + tags: + - '*' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + # list of Docker images to use as base name for tags + images: ghcr.io/blakedewey/phase_unwrap + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8457cb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,350 @@ +### PyCharm template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +.idea + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### PyCharm+all template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/Dockerfile b/Dockerfile index f10660c..26845b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,9 @@ -FROM debian:buster-slim - -RUN apt-get update && \ - apt-get install -y --no-install-recommends build-essential ca-certificates curl - -WORKDIR /opt -RUN curl -o ~/miniconda.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-$(uname -m).sh && \ - chmod +x ~/miniconda.sh && \ - ~/miniconda.sh -b -p /opt/conda && \ - rm ~/miniconda.sh +FROM python:3.12.4-slim-bookworm # Install required packages -RUN /opt/conda/bin/conda install -c defaults -c conda-forge numpy nibabel -ENV PATH /opt/conda/bin:$PATH -RUN pip install nipype +RUN pip install nibabel numpy # Get python scripts COPY phase_unwrap.py /opt -# Add volume mount point -VOLUME /data - -ENTRYPOINT ["python", "/opt/phase_unwrap.py"] +ENTRYPOINT ["python", "/opt/phase_unwrap.py"] \ No newline at end of file diff --git a/phase_unwrap.py b/phase_unwrap.py index fe03dba..0242cb3 100644 --- a/phase_unwrap.py +++ b/phase_unwrap.py @@ -1,178 +1,92 @@ -import os -import shutil import argparse +import os +from pathlib import Path -import numpy as np import nibabel as nib +import numpy as np -from nipype import Workflow, Node -from nipype.interfaces.base import (BaseInterface, BaseInterfaceInputSpec, TraitedSpec, traits, File, - isdefined) -from nipype.utils.filemanip import split_filename - -np.seterr(all='ignore') - +np.seterr(all="ignore") -class Check3DInputSpec(BaseInterfaceInputSpec): - input_image = File(exists=True, desc='image to check', mandatory=True) - volume_num = traits.Int(default_value=0, desc='3D volume to extract (0 count)', usedefault=True) +ORIENT_DICT = {"R": "L", "A": "P", "I": "S", "L": "R", "P": "A", "S": "I"} +GAUSS_STDEV = 10.0 -class Check3DOutputSpec(TraitedSpec): - out_file = File(desc='3d image') +def check_3d(obj: nib.Nifti1Image) -> nib.Nifti1Image: + if len(obj.shape) > 3: + obj_list = nib.four_to_three(obj) + obj = obj_list[1] # Assume phase image is 2nd volume + return obj -class Check3D(BaseInterface): - input_spec = Check3DInputSpec - output_spec = Check3DOutputSpec - - def _run_interface(self, runtime): - obj = nib.load(self.inputs.input_image) - if len(obj.shape) > 3: - obj_list = nib.four_to_three(obj) - obj_list[self.inputs.volume_num].to_filename(split_filename(self.inputs.input_image)[1] + '_3d.nii.gz') - else: - obj.to_filename(split_filename(self.inputs.input_image)[1] + '_3d.nii.gz') - - return runtime - - def _list_outputs(self): - outputs = self._outputs().get() - outputs['out_file'] = os.path.abspath(split_filename(self.inputs.input_image)[1] + '_3d.nii.gz') - return outputs - - -class ReorientInputSpec(BaseInterfaceInputSpec): - input_image = File(exists=True, desc='image to check', mandatory=True) - orientation = traits.String('RAI', desc='orientation string', mandatory=True, usedefault=True) - - -class ReorientOutputSpec(TraitedSpec): - out_file = File(desc='reoriented image') - - -class Reorient(BaseInterface): - input_spec = ReorientInputSpec - output_spec = ReorientOutputSpec - - def _run_interface(self, runtime): - orient_dict = {'R': 'L', 'A': 'P', 'I': 'S', 'L': 'R', 'P': 'A', 'S': 'I'} - - obj = nib.load(self.inputs.input_image) - target_orient = [orient_dict[char] for char in self.inputs.orientation] - if nib.aff2axcodes(obj.affine) != tuple(target_orient): - orig_ornt = nib.orientations.io_orientation(obj.affine) - targ_ornt = nib.orientations.axcodes2ornt(target_orient) - ornt_xfm = nib.orientations.ornt_transform(orig_ornt, targ_ornt) - - affine = obj.affine.dot(nib.orientations.inv_ornt_aff(ornt_xfm, obj.shape)) - data = nib.orientations.apply_orientation(obj.dataobj, ornt_xfm) - obj_new = nib.Nifti1Image(data, affine, obj.header) - obj_new.to_filename(split_filename(self.inputs.input_image)[1] + '_reorient.nii.gz') - else: - obj.to_filename(split_filename(self.inputs.input_image)[1] + '_reorient.nii.gz') - - return runtime - - def _list_outputs(self): - outputs = self._outputs().get() - outputs['out_file'] = os.path.abspath(split_filename(self.inputs.input_image)[1] + '_reorient.nii.gz') - return outputs - - -class UnwrapPhaseInputSpec(BaseInterfaceInputSpec): - phase = File(exists=True, desc='T2* Phase Image', mandatory=True) - gauss_stdev = traits.Int(10, desc='Std Dev of Gaussian for HP Filter', mandatory=True, usedefault=True) - scaled_phase = File(desc='Scaled Phase Image [-pi, pi]') - unwrapped_phase = File(desc='Unwrapped Phase Image') - - -class UnwrapPhaseOutputSpec(TraitedSpec): - scaled_phase = File(exists=True, desc='Scaled Phase Image [-pi, pi]') - unwrapped_phase = File(exists=True, desc='Unwrapped Phase Image') - - -class UnwrapPhase(BaseInterface): - input_spec = UnwrapPhaseInputSpec - output_spec = UnwrapPhaseOutputSpec - - def _run_interface(self, runtime): - phase_obj = nib.load(self.inputs.phase) - phase_data = phase_obj.get_fdata().astype(np.float32) - if phase_data.max() > 3.15: - if phase_data.min() >= 0: - norm_phase = ((phase_data / phase_data.max()) * 2 * np.pi) - np.pi - else: - norm_phase = (phase_data / phase_data.max()) * np.pi - else: - norm_phase = phase_data - - dim = norm_phase.shape - tmp = np.array(np.array(range(int(np.floor(-dim[1] / 2)), int(np.floor(dim[1] / 2)))) / float(dim[1])) - tmp = tmp.reshape((1, dim[1])) - uu = np.ones((1, dim[0])) - xx = np.dot(tmp.conj().T, uu).conj().T - tmp = np.array(np.array(range(int(np.floor(-dim[0] / 2)), int(np.floor(dim[0] / 2)))) / float(dim[0])) - tmp = tmp.reshape((1, dim[0])) - uu = np.ones((dim[1], 1)) - yy = np.dot(uu, tmp).conj().T - kk2 = xx ** 2 + yy ** 2 - hp1 = gauss_filter(dim[0], self.inputs.gauss_stdev, dim[1], self.inputs.gauss_stdev) - - filter_phase = np.zeros_like(norm_phase) - for i in range(dim[2]): - z_slice = norm_phase[:, :, i] - lap_sin = -4.0 * (np.pi ** 2) * icfft(kk2 * cfft(np.sin(z_slice))) - lap_cos = -4.0 * (np.pi ** 2) * icfft(kk2 * cfft(np.cos(z_slice))) - lap_theta = np.cos(z_slice) * lap_sin - np.sin(z_slice) * lap_cos - tmp = np.array(-cfft(lap_theta) / (4.0 * (np.pi ** 2) * kk2)) - tmp[np.isnan(tmp)] = 1.0 - tmp[np.isinf(tmp)] = 1.0 - kx2 = (tmp * (1 - hp1)) - filter_phase[:, :, i] = np.real(icfft(kx2)) - - filter_phase[filter_phase > np.pi] = np.pi - filter_phase[filter_phase < -np.pi] = -np.pi - filter_phase *= -1.0 - - scaled_obj = nib.Nifti1Image(norm_phase, None, phase_obj.header) - scaled_obj.set_data_dtype(np.float32) - if isdefined(self.inputs.scaled_phase): - scaled_obj.to_filename(self.inputs.scaled_phase) - else: - scaled_obj.to_filename(split_filename(self.inputs.phase)[1] + '_scaled.nii.gz') +def reorient(obj: nib.Nifti1Image, orientation: str) -> nib.Nifti1Image: + target_orient = [ORIENT_DICT[char] for char in orientation] + if nib.aff2axcodes(obj.affine) != tuple(target_orient): + orig_ornt = nib.orientations.io_orientation(obj.affine) + targ_ornt = nib.orientations.axcodes2ornt(target_orient) + ornt_xfm = nib.orientations.ornt_transform(orig_ornt, targ_ornt) - filter_obj = nib.Nifti1Image(filter_phase, None, phase_obj.header) - filter_obj.set_data_dtype(np.float32) - if isdefined(self.inputs.unwrapped_phase): - filter_obj.to_filename(self.inputs.unwrapped_phase) - else: - filter_obj.to_filename(split_filename(self.inputs.phase)[1] + '_unwrapped.nii.gz') + affine = obj.affine.dot(nib.orientations.inv_ornt_aff(ornt_xfm, obj.shape)) + data = nib.orientations.apply_orientation(obj.dataobj, ornt_xfm) + obj = nib.Nifti1Image(data, affine, obj.header) + return obj - return runtime - def _list_outputs(self): - outputs = self._outputs().get() - if isdefined(self.inputs.scaled_phase): - outputs['scaled_phase'] = self.inputs.scaled_phase - else: - outputs['scaled_phase'] = os.path.abspath(split_filename(self.inputs.phase)[1] + '_scaled.nii.gz') - if isdefined(self.inputs.unwrapped_phase): - outputs['unwrapped_phase'] = self.inputs.unwrapped_phase +def unwrap_phase(phase_obj: nib.Nifti1Image) -> nib.Nifti1Image: + phase_data = phase_obj.get_fdata().astype(np.float32) + if phase_data.max() > 3.15: + if phase_data.min() >= 0: + norm_phase = ((phase_data / phase_data.max()) * 2 * np.pi) - np.pi else: - outputs['unwrapped_phase'] = os.path.abspath(split_filename(self.inputs.phase)[1] + '_unwrapped.nii.gz') - return outputs - - -def cfft(img_array): + norm_phase = (phase_data / phase_data.max()) * np.pi + else: + norm_phase = phase_data + + dim = norm_phase.shape + tmp = np.array( + np.array(range(int(np.floor(-dim[1] / 2)), int(np.floor(dim[1] / 2)))) / float(dim[1]) + ) + tmp = tmp.reshape((1, dim[1])) + uu = np.ones((1, dim[0])) + xx = np.dot(tmp.conj().T, uu).conj().T + tmp = np.array( + np.array(range(int(np.floor(-dim[0] / 2)), int(np.floor(dim[0] / 2)))) / float(dim[0]) + ) + tmp = tmp.reshape((1, dim[0])) + uu = np.ones((dim[1], 1)) + yy = np.dot(uu, tmp).conj().T + kk2 = xx**2 + yy**2 + hp1 = gauss_filter(dim[0], GAUSS_STDEV, dim[1], GAUSS_STDEV) + + filter_phase = np.zeros_like(norm_phase) + for i in range(dim[2]): + z_slice = norm_phase[:, :, i] + lap_sin = -4.0 * (np.pi**2) * icfft(kk2 * cfft(np.sin(z_slice))) + lap_cos = -4.0 * (np.pi**2) * icfft(kk2 * cfft(np.cos(z_slice))) + lap_theta = np.cos(z_slice) * lap_sin - np.sin(z_slice) * lap_cos + tmp = np.array(-cfft(lap_theta) / (4.0 * (np.pi**2) * kk2)) + tmp[np.isnan(tmp)] = 1.0 + tmp[np.isinf(tmp)] = 1.0 + kx2 = tmp * (1 - hp1) + filter_phase[:, :, i] = np.real(icfft(kx2)) + + filter_phase[filter_phase > np.pi] = np.pi + filter_phase[filter_phase < -np.pi] = -np.pi + filter_phase *= -1.0 + + filter_obj = nib.Nifti1Image(filter_phase, phase_obj.affine, phase_obj.header) + filter_obj.set_data_dtype(np.float32) + return filter_obj + + +def cfft(img_array: np.ndarray) -> np.ndarray: return np.fft.fftshift(np.fft.fft2(np.fft.fftshift(img_array))) -def icfft(freq_array): +def icfft(freq_array: np.ndarray) -> np.ndarray: return np.fft.fftshift(np.fft.ifft2(np.fft.fftshift(freq_array))) -def gauss_filter(dimx, stdevx, dimy, stdevy): +def gauss_filter(dimx: int, stdevx: float, dimy: int, stdevy: float) -> np.ndarray: if dimx % 2 == 0: centerx = (dimx / 2.0) + 1 else: @@ -190,65 +104,35 @@ def gauss_filter(dimx, stdevx, dimy, stdevy): return h -def gauss(r, std0): - return np.exp(-(r ** 2) / (2 * (std0 ** 2))) / (std0 * np.sqrt(2 * np.pi)) - - -class MoveResultFileInputSpec(BaseInterfaceInputSpec): - in_file = File(exists=True, desc='input file to be renamed', mandatory=True) - output_name = traits.String(desc='output name string') - +def gauss(r: np.ndarray, std0: float) -> np.ndarray: + return np.exp(-(r**2) / (2 * (std0**2))) / (std0 * np.sqrt(2 * np.pi)) -class MoveResultFileOutputSpec(TraitedSpec): - out_file = File(desc='path of moved file') +def main(args=None): + parser = argparse.ArgumentParser() + parser.add_argument("phase-image", type=Path) + parser.add_argument("-o", "--output", type=Path) + parser.add_argument("--orientation", type=str) + parser.add_argument("--threads", type=int, default=1) + parsed = parser.parse_args(args) + + os.environ["OMP_NUM_THREADS"] = str(parsed.threads) + + parsed.phase_image = parsed.phase_image.resolve() + if parsed.output is None: + parsed.output = parsed.phase_image.parent / parsed.phase_image.replace( + ".nii.gz", "_unwrapped.nii.gz" + ) + else: + parsed.output = parsed.output.resolve() -class MoveResultFile(BaseInterface): - input_spec = MoveResultFileInputSpec - output_spec = MoveResultFileOutputSpec - - def _run_interface(self, runtime): - shutil.copyfile(self.inputs.in_file, self.inputs.output_name) - return runtime - - def _list_outputs(self): - outputs = self._outputs().get() - outputs['out_file'] = self.inputs.output_name - return outputs + obj = reorient( + check_3d(nib.Nifti1Image.load(parsed.phase_image)), + parsed.orientation, + ) + filter_obj = unwrap_phase(obj) + filter_obj.to_filename(parsed.output) -if __name__ == '__main__': - parser = argparse.ArgumentParser() - parser.add_argument('-p', '--phase-image', type=str, default='T2STAR_PHA.nii.gz') - parser.add_argument('-o', '--output-prefix', type=str, default='T2STAR_PHA') - parser.add_argument('--orientation', type=str, default='RAI') - parser.add_argument('--threads', type=int, default=1) - args = parser.parse_args() - - os.environ['OMP_NUM_THREADS'] = str(args.threads) - - for argname in ['phase_image', 'output_prefix']: - setattr(args, argname, os.path.join('/data', getattr(args, argname))) - - wf = Workflow('qsm') - - # Check3D (choose 2nd volume) - check3d_phase = Node(Check3D(volume_num=1), 'check3d_phase') - check3d_phase.inputs.input_image = args.phase_image - check3d_phase.inputs.volume_num = 1 - - # Reorient phase image - reorient_phase = Node(Reorient(), 'reorient_phase') - reorient_phase.inputs.orientation = args.orientation - wf.connect([(check3d_phase, reorient_phase, [('out_file', 'input_image')])]) - - # Unwrap phase image - unwrap_phase = Node(UnwrapPhase(), 'unwrap_phase') - wf.connect([(reorient_phase, unwrap_phase, [('out_file', 'phase')])]) - - # Move unwrapped phase image to output - move_unwrapped_phase = Node(MoveResultFile(), 'move_unwrapped_phase') - move_unwrapped_phase.inputs.output_name = args.output_prefix + '_UNWRAPPED.nii.gz' - wf.connect([(unwrap_phase, move_unwrapped_phase, [('unwrapped_phase', 'in_file')])]) - - wf.run() +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..947ac6d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +nibabel +numpy \ No newline at end of file From 2e6b152d11c7c17b48b29715fbf2f900115efc03 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 10:04:19 -0400 Subject: [PATCH 02/18] typo in arg naming --- phase_unwrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phase_unwrap.py b/phase_unwrap.py index 0242cb3..b2c74a8 100644 --- a/phase_unwrap.py +++ b/phase_unwrap.py @@ -110,7 +110,7 @@ def gauss(r: np.ndarray, std0: float) -> np.ndarray: def main(args=None): parser = argparse.ArgumentParser() - parser.add_argument("phase-image", type=Path) + parser.add_argument("phase_image", type=Path) parser.add_argument("-o", "--output", type=Path) parser.add_argument("--orientation", type=str) parser.add_argument("--threads", type=int, default=1) From 2ed144ec63fd664171e8e4efa1dca73a8e8d637d Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 10:12:17 -0400 Subject: [PATCH 03/18] replace in name --- phase_unwrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phase_unwrap.py b/phase_unwrap.py index b2c74a8..c6ceb5d 100644 --- a/phase_unwrap.py +++ b/phase_unwrap.py @@ -120,7 +120,7 @@ def main(args=None): parsed.phase_image = parsed.phase_image.resolve() if parsed.output is None: - parsed.output = parsed.phase_image.parent / parsed.phase_image.replace( + parsed.output = parsed.phase_image.parent / parsed.phase_image.name.replace( ".nii.gz", "_unwrapped.nii.gz" ) else: From c6326f042e8e248d996893916dcc1a0a88fb45e9 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 10:27:35 -0400 Subject: [PATCH 04/18] keep orientation if not specified. remove threads option. localize numpy errstate. --- phase_unwrap.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/phase_unwrap.py b/phase_unwrap.py index c6ceb5d..e2809e0 100644 --- a/phase_unwrap.py +++ b/phase_unwrap.py @@ -1,12 +1,9 @@ import argparse -import os from pathlib import Path import nibabel as nib import numpy as np -np.seterr(all="ignore") - ORIENT_DICT = {"R": "L", "A": "P", "I": "S", "L": "R", "P": "A", "S": "I"} GAUSS_STDEV = 10.0 @@ -19,6 +16,8 @@ def check_3d(obj: nib.Nifti1Image) -> nib.Nifti1Image: def reorient(obj: nib.Nifti1Image, orientation: str) -> nib.Nifti1Image: + if orientation is None: + return obj target_orient = [ORIENT_DICT[char] for char in orientation] if nib.aff2axcodes(obj.affine) != tuple(target_orient): orig_ornt = nib.orientations.io_orientation(obj.affine) @@ -58,16 +57,17 @@ def unwrap_phase(phase_obj: nib.Nifti1Image) -> nib.Nifti1Image: hp1 = gauss_filter(dim[0], GAUSS_STDEV, dim[1], GAUSS_STDEV) filter_phase = np.zeros_like(norm_phase) - for i in range(dim[2]): - z_slice = norm_phase[:, :, i] - lap_sin = -4.0 * (np.pi**2) * icfft(kk2 * cfft(np.sin(z_slice))) - lap_cos = -4.0 * (np.pi**2) * icfft(kk2 * cfft(np.cos(z_slice))) - lap_theta = np.cos(z_slice) * lap_sin - np.sin(z_slice) * lap_cos - tmp = np.array(-cfft(lap_theta) / (4.0 * (np.pi**2) * kk2)) - tmp[np.isnan(tmp)] = 1.0 - tmp[np.isinf(tmp)] = 1.0 - kx2 = tmp * (1 - hp1) - filter_phase[:, :, i] = np.real(icfft(kx2)) + with np.errstate(divide="ignore"): + for i in range(dim[2]): + z_slice = norm_phase[:, :, i] + lap_sin = -4.0 * (np.pi**2) * icfft(kk2 * cfft(np.sin(z_slice))) + lap_cos = -4.0 * (np.pi**2) * icfft(kk2 * cfft(np.cos(z_slice))) + lap_theta = np.cos(z_slice) * lap_sin - np.sin(z_slice) * lap_cos + tmp = np.array(-cfft(lap_theta) / (4.0 * (np.pi**2) * kk2)) + tmp[np.isnan(tmp)] = 1.0 + tmp[np.isinf(tmp)] = 1.0 + kx2 = tmp * (1 - hp1) + filter_phase[:, :, i] = np.real(icfft(kx2)) filter_phase[filter_phase > np.pi] = np.pi filter_phase[filter_phase < -np.pi] = -np.pi @@ -113,11 +113,8 @@ def main(args=None): parser.add_argument("phase_image", type=Path) parser.add_argument("-o", "--output", type=Path) parser.add_argument("--orientation", type=str) - parser.add_argument("--threads", type=int, default=1) parsed = parser.parse_args(args) - os.environ["OMP_NUM_THREADS"] = str(parsed.threads) - parsed.phase_image = parsed.phase_image.resolve() if parsed.output is None: parsed.output = parsed.phase_image.parent / parsed.phase_image.name.replace( From a255aed497b7f42fbfbd141850d0497e2a29f889 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 16:16:01 -0400 Subject: [PATCH 05/18] Refactor as package. Add versioning and pypi publishing. --- .gitattributes | 1 + .github/workflows/pypi-publish.yml | 32 +++++ Dockerfile | 8 +- README.md | 55 ++++++- phase_unwrap/__init__.py | 1 + phase_unwrap/_static_version.py | 12 ++ phase_unwrap/_version.py | 190 +++++++++++++++++++++++++ phase_unwrap.py => phase_unwrap/cli.py | 26 ++-- setup.py | 60 ++++++++ 9 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 phase_unwrap/__init__.py create mode 100644 phase_unwrap/_static_version.py create mode 100644 phase_unwrap/_version.py rename phase_unwrap.py => phase_unwrap/cli.py (84%) create mode 100644 setup.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5cc2687 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +phase_unwrap/_static_version.py export-subst \ No newline at end of file diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..babeb1c --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,32 @@ +name: Publish to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + pypi-publish: + name: Build and Upload Release to PyPI + runs-on: ubuntu-latest + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref_name }} + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.x + - name: Build distribution + run: | + python -m pip install --upgrade build + python -m build + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + url: https://test.pypi.org/legacy/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 26845b5..ab959cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,9 @@ FROM python:3.12.4-slim-bookworm # Install required packages RUN pip install nibabel numpy -# Get python scripts -COPY phase_unwrap.py /opt +# Install python package +COPY phase_unwrap /tmp -ENTRYPOINT ["python", "/opt/phase_unwrap.py"] \ No newline at end of file +RUN pip install /tmp/phase_unwrap + +ENTRYPOINT ["phase-unwrap"] \ No newline at end of file diff --git a/README.md b/README.md index 6e0aa33..ddd93d7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,56 @@ # MRI Phase Unwrapping - [![DOI](https://zenodo.org/badge/551228762.svg)](https://zenodo.org/badge/latestdoi/551228762) - A simple, laplacian-based unwrapping pipeline for MRI phase images. -This processing has been used in a number of published manuscripts detailing phase-rim lesions in multiple sclerosis. - -Absinta et al. "Persistent 7-tesla phase rim predicts poor outcome in new multiple sclerosis patient lesions" *Journal of Clinical Investigation* (doi: [10.1172/JCI86198](https://doi.org/10.1172%2FJCI86198)) - If you use this code in your work, please cite: +``` +Blake E. Dewey. (2022). Laplacian-based Phase Unwrapping in Python. Zenodo. [https://doi.org/10.5281/zenodo.7198990](https://doi.org/10.5281/zenodo.7198991) +``` +## Install with `pip` +```bash +pip install phase_unwrap +``` + +## Basic Usage +```bash +phase-unwrap /path/to/phase_image.nii.gz +``` +### CLI Options +| Option | Description | Default | +|-------------------|-------------------------------------------------------------|----------| +| | Path to phase image | Required | +| `-o`/`--output` | Output path | Optional | +| `--orientation` | Reorient to this before unwrapping (`RAI`, `RSA`, or `ASR`) | Optional | +| `--undo-reorient` | Return image to orientation after unwrapping | `False` | + +Using the `--output` option will save the unwrapped image to that path. +If not provided, the unwrapped image will be saved to the same directory as the input image with `_unwrapped` appended to the filename (before the `.nii.gz`). + +`--orientation` can be used to reorient the image before unwrapping. +This script uses 2D functions for unwrapping and will slice the data according to the slice direction of the volume (last dimension). +If the slice direction is not the desired orientation, use this option to reorient the image before unwrapping. +`RAI` will give you an axial image. `RSA` will give you a coronal image. `ASR` will give you a sagittal image. +If you would like the unwrapped image returned in the original orientation, use the `--undo-reorient` option. + +## Docker Usage +The docker file in this package is used to build a container with the necessary dependencies to run the unwrapping script. +You can pull it directly from Docker Hub with: +```bash +docker pull blakedewey/phase_unwrap:v2.0.0 +``` + +After pulling the image, you can run the unwrapping script with: +```bash +docker run -v /path/to/data:/data blakedewey/phase_unwrap:v2.0.0 /data/phase_image.nii.gz +``` + +All of the same CLI options will work with the Docker container as well. +Remember to mount the directory containing the data to a place in the container (`/data` in the example). +You will also have to specify the path to the image relative to the mounted directory. + +## Works Using This Code +This processing has been used in a number of published manuscripts detailing phase-rim lesions in multiple sclerosis. -Blake E. Dewey. (2022). Laplacian-based Phase Unwrapping in Python (v1.0). Zenodo.[https://doi.org/10.5281/zenodo.7198991](https://doi.org/10.5281/zenodo.7198991) +1. Absinta et al. "Persistent 7-tesla phase rim predicts poor outcome in new multiple sclerosis patient lesions" *Journal of Clinical Investigation* (doi: [10.1172/JCI86198](https://doi.org/10.1172%2FJCI86198)) diff --git a/phase_unwrap/__init__.py b/phase_unwrap/__init__.py new file mode 100644 index 0000000..7df9f7a --- /dev/null +++ b/phase_unwrap/__init__.py @@ -0,0 +1 @@ +from ._version import __version__ \ No newline at end of file diff --git a/phase_unwrap/_static_version.py b/phase_unwrap/_static_version.py new file mode 100644 index 0000000..5557f9b --- /dev/null +++ b/phase_unwrap/_static_version.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# This file is part of 'miniver': https://github.com/jbweston/miniver +# +# This file will be overwritten by setup.py when a source or binary +# distribution is made. The magic value "__use_git__" is interpreted by +# version.py. + +version = "__use_git__" + +# These values are only set if the distribution was created with 'git archive' +refnames = "$Format:%D$" +git_hash = "$Format:%h$" diff --git a/phase_unwrap/_version.py b/phase_unwrap/_version.py new file mode 100644 index 0000000..21abb63 --- /dev/null +++ b/phase_unwrap/_version.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# This file is part of 'miniver': https://github.com/jbweston/miniver +# +from collections import namedtuple +import os +import subprocess + +from setuptools.command.build_py import build_py as build_py_orig +from setuptools.command.sdist import sdist as sdist_orig + +Version = namedtuple("Version", ("release", "dev", "labels")) + +# No public API +__all__ = [] + +package_root = os.path.dirname(os.path.realpath(__file__)) +package_name = os.path.basename(package_root) + +STATIC_VERSION_FILE = "_static_version.py" + + +def get_version(version_file=STATIC_VERSION_FILE): + version_info = get_static_version_info(version_file) + version = version_info["version"] + if version == "__use_git__": + version = get_version_from_git() + if not version: + version = get_version_from_git_archive(version_info) + if not version: + version = Version("unknown", None, None) + return pep440_format(version) + else: + return version + + +def get_static_version_info(version_file=STATIC_VERSION_FILE): + version_info = {} + with open(os.path.join(package_root, version_file), "rb") as f: + exec(f.read(), {}, version_info) + return version_info + + +def version_is_from_git(version_file=STATIC_VERSION_FILE): + return get_static_version_info(version_file)["version"] == "__use_git__" + + +def pep440_format(version_info): + release, dev, labels = version_info + + version_parts = [release] + if dev: + if release.endswith("-dev") or release.endswith(".dev"): + version_parts.append(dev) + else: # prefer PEP440 over strict adhesion to semver + version_parts.append(".dev{}".format(dev)) + + if labels: + version_parts.append("+") + version_parts.append(".".join(labels)) + + return "".join(version_parts) + + +def get_version_from_git(): + # git describe --first-parent does not take into account tags from branches + # that were merged-in. The '--long' flag gets us the 'dev' version and + # git hash, '--always' returns the git hash even if there are no tags. + for opts in [["--first-parent"], []]: + try: + p = subprocess.Popen( + ["git", "describe", "--long", "--always"] + opts, + cwd=package_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError: + return + if p.wait() == 0: + break + else: + return + + description = ( + p.communicate()[0] + .decode() + .strip("v") # Tags can have a leading 'v', but the version should not + .rstrip("\n") + .rsplit("-", 2) # Split the latest tag, commits since tag, and hash + ) + + try: + release, dev, git = description + except ValueError: # No tags, only the git hash + # prepend 'g' to match with format returned by 'git describe' + git = "g{}".format(*description) + release = "unknown" + dev = None + + labels = [] + if dev == "0": + dev = None + else: + labels.append(git) + + try: + p = subprocess.Popen(["git", "diff", "--quiet"], cwd=package_root) + except OSError: + labels.append("confused") # This should never happen. + else: + if p.wait() == 1: + labels.append("dirty") + + return Version(release, dev, labels) + + +# TODO: change this logic when there is a git pretty-format +# that gives the same output as 'git describe'. +# Currently we can only tell the tag the current commit is +# pointing to, or its hash (with no version info) +# if it is not tagged. +def get_version_from_git_archive(version_info): + try: + refnames = version_info["refnames"] + git_hash = version_info["git_hash"] + except KeyError: + # These fields are not present if we are running from an sdist. + # Execution should never reach here, though + return None + + if git_hash.startswith("$Format") or refnames.startswith("$Format"): + # variables not expanded during 'git archive' + return None + + VTAG = "tag: v" + refs = set(r.strip() for r in refnames.split(",")) + version_tags = set(r[len(VTAG) :] for r in refs if r.startswith(VTAG)) + if version_tags: + release, *_ = sorted(version_tags) # prefer e.g. "2.0" over "2.0rc1" + return Version(release, dev=None, labels=None) + else: + return Version("unknown", dev=None, labels=["g{}".format(git_hash)]) + + +__version__ = get_version() + + +# The following section defines a 'get_cmdclass' function +# that can be used from setup.py. The '__version__' module +# global is used (but not modified). + + +def _write_version(fname): + # This could be a hard link, so try to delete it first. Is there any way + # to do this atomically together with opening? + try: + os.remove(fname) + except OSError: + pass + with open(fname, "w") as f: + f.write( + "# This file has been created by setup.py.\n" + "version = '{}'\n".format(__version__) + ) + + +def get_cmdclass(pkg_source_path): + class _build_py(build_py_orig): + def run(self): + super().run() + + src_marker = "".join(["src", os.path.sep]) + + if pkg_source_path.startswith(src_marker): + path = pkg_source_path[len(src_marker):] + else: + path = pkg_source_path + _write_version( + os.path.join( + self.build_lib, path, STATIC_VERSION_FILE + ) + ) + + class _sdist(sdist_orig): + def make_release_tree(self, base_dir, files): + super().make_release_tree(base_dir, files) + _write_version( + os.path.join(base_dir, pkg_source_path, STATIC_VERSION_FILE) + ) + + return dict(sdist=_sdist, build_py=_build_py) diff --git a/phase_unwrap.py b/phase_unwrap/cli.py similarity index 84% rename from phase_unwrap.py rename to phase_unwrap/cli.py index e2809e0..7dc224c 100644 --- a/phase_unwrap.py +++ b/phase_unwrap/cli.py @@ -10,6 +10,7 @@ def check_3d(obj: nib.Nifti1Image) -> nib.Nifti1Image: if len(obj.shape) > 3: + print("Input image is 4D, assuming phase image is 2nd volume.") obj_list = nib.four_to_three(obj) obj = obj_list[1] # Assume phase image is 2nd volume return obj @@ -20,6 +21,7 @@ def reorient(obj: nib.Nifti1Image, orientation: str) -> nib.Nifti1Image: return obj target_orient = [ORIENT_DICT[char] for char in orientation] if nib.aff2axcodes(obj.affine) != tuple(target_orient): + print(f"Reorienting image to {orientation}.") orig_ornt = nib.orientations.io_orientation(obj.affine) targ_ornt = nib.orientations.axcodes2ornt(target_orient) ornt_xfm = nib.orientations.ornt_transform(orig_ornt, targ_ornt) @@ -31,6 +33,7 @@ def reorient(obj: nib.Nifti1Image, orientation: str) -> nib.Nifti1Image: def unwrap_phase(phase_obj: nib.Nifti1Image) -> nib.Nifti1Image: + print("Unwrapping phase image.") phase_data = phase_obj.get_fdata().astype(np.float32) if phase_data.max() > 3.15: if phase_data.min() >= 0: @@ -57,7 +60,7 @@ def unwrap_phase(phase_obj: nib.Nifti1Image) -> nib.Nifti1Image: hp1 = gauss_filter(dim[0], GAUSS_STDEV, dim[1], GAUSS_STDEV) filter_phase = np.zeros_like(norm_phase) - with np.errstate(divide="ignore"): + with np.errstate(divide="ignore", invalid="ignore"): for i in range(dim[2]): z_slice = norm_phase[:, :, i] lap_sin = -4.0 * (np.pi**2) * icfft(kk2 * cfft(np.sin(z_slice))) @@ -113,8 +116,14 @@ def main(args=None): parser.add_argument("phase_image", type=Path) parser.add_argument("-o", "--output", type=Path) parser.add_argument("--orientation", type=str) + parser.add_argument('--undo-reorient', action='store_true') parsed = parser.parse_args(args) + print(""" + If you are using this software in a publication, please cite the following: + Blake E. Dewey. (2022). Laplacian-based Phase Unwrapping in Python. Zenodo. https://doi.org/10.5281/zenodo.7198990 + """) + parsed.phase_image = parsed.phase_image.resolve() if parsed.output is None: parsed.output = parsed.phase_image.parent / parsed.phase_image.name.replace( @@ -123,13 +132,14 @@ def main(args=None): else: parsed.output = parsed.output.resolve() - obj = reorient( - check_3d(nib.Nifti1Image.load(parsed.phase_image)), - parsed.orientation, - ) + obj = nib.Nifti1Image.load(parsed.phase_image) + + orig_orientation = ''.join([ORIENT_DICT[i] for i in nib.aff2axcodes(obj.affine)]) + obj = reorient(check_3d(obj), parsed.orientation) + filter_obj = unwrap_phase(obj) - filter_obj.to_filename(parsed.output) + if parsed.undo_reorient: + filter_obj = reorient(filter_obj, orig_orientation) -if __name__ == "__main__": - main() + filter_obj.to_filename(parsed.output) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7829f0e --- /dev/null +++ b/setup.py @@ -0,0 +1,60 @@ +from pathlib import Path +from setuptools import setup, find_packages + +__package_name__ = "phase_unwrap" + + +def get_version_and_cmdclass(pkg_path): + """Load version.py module without importing the whole package. + + Template code from miniver + """ + import os + from importlib.util import module_from_spec, spec_from_file_location + + spec = spec_from_file_location("version", os.path.join(pkg_path, "_version.py")) + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module.__version__, module.get_cmdclass(pkg_path) + + +__version__, cmdclass = get_version_and_cmdclass(__package_name__) + +setup( + name=__package_name__, + version=__version__, + description=( + "A simple, laplacian-based unwrapping pipeline for MRI phase images in Python." + ), + long_description=(Path(__file__).parent.resolve() / "README.md").read_text(), + long_description_content_type="text/markdown", + author="Blake Dewey", + author_email="blake.dewey@jhu.edu", + url="https://github.com/blakedewey/phase_unwrap", + license="GPL-3.0", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + packages=find_packages(), + keywords="mri phase unwrapping", + entry_points={ + "console_scripts": [ + "unwrap-phase=phase_unwrap.cli:main", + ] + }, + python_requires=">=3.8", + install_requires=[ + "nibabel", + "numpy", + ], + cmdclass=cmdclass, +) From c3ce98e2bffd2488eb73c41f5a3d0244bae2f94c Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 16:19:51 -0400 Subject: [PATCH 06/18] Fix Dockerfile --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ab959cc..938c287 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,9 @@ FROM python:3.12.4-slim-bookworm RUN pip install nibabel numpy # Install python package -COPY phase_unwrap /tmp +COPY . /tmp/phase_unwrap-src -RUN pip install /tmp/phase_unwrap +RUN pip install --no-cache-dir /tmp/phase_unwrap-src/ && \ + rm -rf /tmp/phase_unwrap-src/ ENTRYPOINT ["phase-unwrap"] \ No newline at end of file From f76c891091d4bbc07b3572ec764c05ad28615c22 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 16:32:58 -0400 Subject: [PATCH 07/18] get git repo info for version --- .github/workflows/build-push-docker.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index f0160aa..ed96a75 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -9,6 +9,12 @@ jobs: docker: runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref_name }} + - name: Docker meta id: meta uses: docker/metadata-action@v5 From 9f2eb466f1569197ba4dd774ff7c84999c8ed631 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 16:41:32 -0400 Subject: [PATCH 08/18] trying version build again --- .github/workflows/build-push-docker.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index ed96a75..e8a52d7 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -39,6 +39,9 @@ jobs: uses: docker/build-push-action@v6 with: platforms: linux/amd64,linux/arm64 + context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILDKIT_CONTEXT_KEEP_GIT_DIR=true From a236ba6e8c54af45cff233aba776687a1a66e3d2 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 16:46:21 -0400 Subject: [PATCH 09/18] need git to do git things --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 938c287..d6e4046 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ FROM python:3.12.4-slim-bookworm +RUN apt update && \ + apt install -y --no-install-recommends ca-certificates git + # Install required packages RUN pip install nibabel numpy From 47085e05afdbaf925dfa0c3a0f0aaeb7a17d0bb9 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 17:07:29 -0400 Subject: [PATCH 10/18] update Dockerfile and README with proper command name. --- Dockerfile | 2 +- README.md | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index d6e4046..a315e9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,4 @@ COPY . /tmp/phase_unwrap-src RUN pip install --no-cache-dir /tmp/phase_unwrap-src/ && \ rm -rf /tmp/phase_unwrap-src/ -ENTRYPOINT ["phase-unwrap"] \ No newline at end of file +ENTRYPOINT ["unwrap-phase"] \ No newline at end of file diff --git a/README.md b/README.md index ddd93d7..8963d40 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,25 @@ If you use this code in your work, please cite: ``` Blake E. Dewey. (2022). Laplacian-based Phase Unwrapping in Python. Zenodo. [https://doi.org/10.5281/zenodo.7198990](https://doi.org/10.5281/zenodo.7198991) ``` -## Install with `pip` + +## Install with pip ```bash pip install phase_unwrap ``` ## Basic Usage ```bash -phase-unwrap /path/to/phase_image.nii.gz +unwrap-phase /path/to/phase_image.nii.gz +``` +This will produce an unwrapped phase image in the same directory as the input image with `_unwrapped` appended to the filename. + +**NOTE:** Some have reported the best results when the phase image is reoriented to the axial plane before unwrapping. +This can be done with the `--orientation` option: +```bash +unwrap-phase /path/to/phase_image.nii.gz --orientation RAI ``` +If you would like the unwrapped image returned in the original orientation, use the `--undo-reorient` option. + ### CLI Options | Option | Description | Default | |-------------------|-------------------------------------------------------------|----------| From 3874a18a23a2d6727812f0ba5d6271ab5c9e5c69 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 17:11:06 -0400 Subject: [PATCH 11/18] try consolidated run --- Dockerfile | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index a315e9e..19f908d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,14 @@ FROM python:3.12.4-slim-bookworm -RUN apt update && \ - apt install -y --no-install-recommends ca-certificates git - -# Install required packages -RUN pip install nibabel numpy - -# Install python package COPY . /tmp/phase_unwrap-src -RUN pip install --no-cache-dir /tmp/phase_unwrap-src/ && \ - rm -rf /tmp/phase_unwrap-src/ +RUN apt update && \ + apt install -y --no-install-recommends ca-certificates git && \ + pip install --no-cache-dir /tmp/phase_unwrap-src/ && \ + rm -rf /tmp/phase_unwrap-src/ && \ + apt remove -y git && \ + apt autoremove -y && \ + apt clean && \ + rm -rf /var/lib/apt/lists/* ENTRYPOINT ["unwrap-phase"] \ No newline at end of file From 1cbef15414bfc987ab6bb1e273a28abe0a822927 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 19:22:20 -0400 Subject: [PATCH 12/18] Add upgrading instructions --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8963d40..8693ef9 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,20 @@ docker pull blakedewey/phase_unwrap:v2.0.0 After pulling the image, you can run the unwrapping script with: ```bash -docker run -v /path/to/data:/data blakedewey/phase_unwrap:v2.0.0 /data/phase_image.nii.gz +docker run -it --rm -v /path/to/data:/data blakedewey/phase_unwrap:v2.0.0 /data/phase_image.nii.gz ``` All of the same CLI options will work with the Docker container as well. Remember to mount the directory containing the data to a place in the container (`/data` in the example). You will also have to specify the path to the image relative to the mounted directory. +## Upgrading from v1.0.0 +The CLI options have changed slightly from v1.0.0 to v2.0.0: + - The `-p`/`--phase-image` option has been replaced with a positional argument for the path to the phase image. + - In v1.0.0, image paths were assumed to be in `/data` for use in Docker. This is no longer the case. You must specify the full path to the image or output, even in the Docker container. + - In v1.0.0, the default for `--orientation` was `RAI`. This has been removed. If you want to reorient the image, you must specify the orientation. Use `--orientation RAI` to get the same behavior as v1.0.0. + - The `--undo-reorient` option has been added to return the image to the original orientation after unwrapping. + ## Works Using This Code This processing has been used in a number of published manuscripts detailing phase-rim lesions in multiple sclerosis. From 172a0a4a852aab7e99e833fd892ca000f9aa6a1b Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 19:24:12 -0400 Subject: [PATCH 13/18] Add help message, description and custom error messaging to point to repo. Add error messages for non-existing files. --- phase_unwrap/cli.py | 60 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/phase_unwrap/cli.py b/phase_unwrap/cli.py index 7dc224c..c95ab4a 100644 --- a/phase_unwrap/cli.py +++ b/phase_unwrap/cli.py @@ -8,6 +8,15 @@ GAUSS_STDEV = 10.0 +class ArgumentParser(argparse.ArgumentParser): + def error(self, message): + custom_message = ( + f"{message}\n\n" + f"See https://github.com/blakedewey/phase_unwrap for full instructions." + ) + super().error(custom_message) + + def check_3d(obj: nib.Nifti1Image) -> nib.Nifti1Image: if len(obj.shape) > 3: print("Input image is 4D, assuming phase image is 2nd volume.") @@ -112,18 +121,42 @@ def gauss(r: np.ndarray, std0: float) -> np.ndarray: def main(args=None): - parser = argparse.ArgumentParser() - parser.add_argument("phase_image", type=Path) - parser.add_argument("-o", "--output", type=Path) - parser.add_argument("--orientation", type=str) - parser.add_argument('--undo-reorient', action='store_true') + print( + "\n" + "If you are using this software in a publication, please cite the following:\n" + "Blake E. Dewey. (2022). Laplacian-based Phase Unwrapping in Python. Zenodo. " + "https://doi.org/10.5281/zenodo.7198990" + "\n" + ) + parser = ArgumentParser( + description="Unwrap MRI phase images using Laplacian-based phase unwrapping. " + "See https://github.com/blakedewey/phase_unwrap for full instructions." + ) + parser.add_argument( + "phase_image", + metavar="PHASE_IMAGE", + type=Path, + help="Path to input phase image", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + help="Optional output filepath. Default is ${PHASE_IMAGE}_unwrapped.nii.gz", + ) + parser.add_argument( + "--orientation", + type=str, + choices=("RAI", "RSA", "ASR"), + help="Orientation for unwrapping", + ) + parser.add_argument( + "--undo-reorient", + action="store_true", + help="Undo reorientation after unwrapping", + ) parsed = parser.parse_args(args) - print(""" - If you are using this software in a publication, please cite the following: - Blake E. Dewey. (2022). Laplacian-based Phase Unwrapping in Python. Zenodo. https://doi.org/10.5281/zenodo.7198990 - """) - parsed.phase_image = parsed.phase_image.resolve() if parsed.output is None: parsed.output = parsed.phase_image.parent / parsed.phase_image.name.replace( @@ -132,9 +165,14 @@ def main(args=None): else: parsed.output = parsed.output.resolve() + if not parsed.phase_image.exists(): + parser.error(f"Input file not found: {parsed.phase_image}") + if not parsed.output.parent.exists(): + parser.error(f"Output directory not found: {parsed.output.parent}") + obj = nib.Nifti1Image.load(parsed.phase_image) - orig_orientation = ''.join([ORIENT_DICT[i] for i in nib.aff2axcodes(obj.affine)]) + orig_orientation = "".join([ORIENT_DICT[i] for i in nib.aff2axcodes(obj.affine)]) obj = reorient(check_3d(obj), parsed.orientation) filter_obj = unwrap_phase(obj) From fa62e6fc65d35bf00ccde401d4b2540a7a3fc707 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 19:31:25 -0400 Subject: [PATCH 14/18] Change to dockerhub --- .github/workflows/build-push-docker.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index e8a52d7..f46361e 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -28,12 +28,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to GHCR + - name: Login to DockerHub uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} + username: blakedewey + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 From 06c20027917161ff62cf46d26920eb89a9a463bb Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 19:35:36 -0400 Subject: [PATCH 15/18] different image name for dockerhub --- .github/workflows/build-push-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index f46361e..68667bb 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -20,7 +20,7 @@ jobs: uses: docker/metadata-action@v5 with: # list of Docker images to use as base name for tags - images: ghcr.io/blakedewey/phase_unwrap + images: blakedewey/phase_unwrap - name: Set up QEMU uses: docker/setup-qemu-action@v3 From 8dd2931ce6a49166b9a2c96e646ce2f4a103eca1 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 19:39:09 -0400 Subject: [PATCH 16/18] Back to just docker action --- .github/workflows/build-push-docker.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index 68667bb..3aa22a1 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -9,12 +9,6 @@ jobs: docker: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.ref_name }} - - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -38,9 +32,6 @@ jobs: uses: docker/build-push-action@v6 with: platforms: linux/amd64,linux/arm64 - context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - build-args: | - BUILDKIT_CONTEXT_KEEP_GIT_DIR=true From a3e75b123acd4debbd703cc6baf7315ba5b2239f Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 19:40:40 -0400 Subject: [PATCH 17/18] revert to checkout --- .github/workflows/build-push-docker.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index 3aa22a1..68667bb 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -9,6 +9,12 @@ jobs: docker: runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref_name }} + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -32,6 +38,9 @@ jobs: uses: docker/build-push-action@v6 with: platforms: linux/amd64,linux/arm64 + context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILDKIT_CONTEXT_KEEP_GIT_DIR=true From 5fea9e3907b74de8eab12e191d70d750fcf077c4 Mon Sep 17 00:00:00 2001 From: Blake Dewey Date: Mon, 24 Jun 2024 19:51:04 -0400 Subject: [PATCH 18/18] only build docker images for v* tags --- .github/workflows/build-push-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml index 68667bb..735dddb 100644 --- a/.github/workflows/build-push-docker.yml +++ b/.github/workflows/build-push-docker.yml @@ -3,7 +3,7 @@ name: build-push-docker on: push: tags: - - '*' + - 'v*' jobs: docker: