diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..d8aead1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile +{ + "name": "Local Development", + "build": { + // Sets the run context to one level up instead of the .devcontainer folder. + "context": "..", + // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. + "dockerfile": "../docker/development.Dockerfile" + }, + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Uncomment the next line to run commands after the container is created. + "postCreateCommand": "poetry install", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + "mounts": [ + "source=${localWorkspaceFolder},target=/project,type=bind,consistency=cached" + ] +} diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index f174a42..506cefd 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -32,6 +32,25 @@ jobs: with: pytest-xml-coverage-path: ./coverage.xml + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: cardinalby/export-env-action@v2 + id: cicd_env + with: + envFile: 'github.env' + expand: 'true' + - uses: actions/setup-python@v2 + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: abatilo/actions-poetry@v2.0.0 + with: + poetry-version: ${{ env.POETRY_VERSION }} + - run: poetry install --no-interaction + - name: Build docs + run: poetry run mkdocs build + # build: # runs-on: ubuntu-latest # steps: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 6cef8f6..c51d71b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -9,6 +9,13 @@ build: os: ubuntu-22.04 tools: python: "3.8" + jobs: + post_create_environment: + - pip install poetry==1.1.11 + post_install: + # TODO: Change to docs group after poetry upgrade + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install + mkdocs: configuration: mkdocs.yml diff --git a/docker/development.Dockerfile b/docker/development.Dockerfile new file mode 100644 index 0000000..5ac1adc --- /dev/null +++ b/docker/development.Dockerfile @@ -0,0 +1,20 @@ +FROM ubuntu:20.04 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update --yes && \ + apt-get install -y python3-pip + +ENV POETRY_VERSION=1.1.11 +RUN pip3 install --upgrade pip && pip3 install "poetry==$POETRY_VERSION" + + +RUN mkdir /src +WORKDIR /src + +RUN poetry config virtualenvs.create false + +# Install dependencies (cached) +COPY pyproject.toml poetry.lock ./ +RUN poetry install + +CMD ["/bin/bash"] diff --git a/docs/assets/bme_logo.png b/docs/assets/bme_logo.png new file mode 100644 index 0000000..496af36 Binary files /dev/null and b/docs/assets/bme_logo.png differ diff --git a/docs/assets/miccai2022_logo.png b/docs/assets/miccai2022_logo.png new file mode 100644 index 0000000..b7a295c Binary files /dev/null and b/docs/assets/miccai2022_logo.png differ diff --git a/docs/assets/tcml_logo.png b/docs/assets/tcml_logo.png new file mode 100644 index 0000000..1362544 Binary files /dev/null and b/docs/assets/tcml_logo.png differ diff --git a/docs/dataset_preprocessing.md b/docs/dataset_preprocessing.md deleted file mode 100644 index 70549fa..0000000 --- a/docs/dataset_preprocessing.md +++ /dev/null @@ -1,19 +0,0 @@ -# Dataset Preprocessing - -## ADC Calculation - -### Single Map - -To calculate an ADC map, run: - -!!! note "" - pd-dwi-preprocessing adc -dwi_data -b - -### Batch Mode - -To calculate an ADC map for a large number of folders, run: - -!!! note "" - pd-dwi-preprocessing adc -dwi_data -b - -The input file for ADC batch processing is a CSV file where the first row contains a header (skipped in processing), and each subsequent row represents a path for a DWI acquisition folder. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 16e334b..fe0a83d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,11 @@ PD-DWI is a physiologically-decomposed Diffusion-Weighted MRI machine-learning model for predicting response to neoadjuvant chemotherapy in invasive breast cancer. -PD-DWI was developed by [TCML](https://tcml-bme.github.io/) group as part of [BMMR2 challenge](https://wiki.cancerimagingarchive.net/pages/viewpage.action?pageId=89096426) using [ACRIN-6698](https://wiki.cancerimagingarchive.net/pages/viewpage.action?pageId=50135447) dataset. +PD-DWI was developed by [TCML](https://tcml-bme.github.io/) group. + +
+ ![TCML](assets/tcml_logo.png) +
**If you publish any work which uses this package, please cite the following publication:** Gilad, M., Freiman, M. (2022). PD-DWI: Predicting Response to Neoadjuvant Chemotherapy in Invasive Breast Cancer with Physiologically-Decomposed Diffusion-Weighted MRI Machine-Learning Model. In: Wang, L., Dou, Q., Fletcher, P.T., Speidel, S., Li, S. (eds) Medical Image Computing and Computer Assisted Intervention – MICCAI 2022. MICCAI 2022. Lecture Notes in Computer Science, vol 13433. Springer, Cham. https://doi.org/10.1007/978-3-031-16437-8_4 @@ -10,4 +14,45 @@ PD-DWI was developed by [TCML](https://tcml-bme.github.io/) group as part of [BM This work was developed as part of the [BMMR2 challenge](https://wiki.cancerimagingarchive.net/pages/viewpage.action?pageId=89096426) using [ACRIN-6698](https://wiki.cancerimagingarchive.net/pages/viewpage.action?pageId=50135447) dataset. !!! warning - Not intended for clinical use. \ No newline at end of file + Not intended for clinical use. + +## BMMR2 Challenge + +``` plotly +{ + "data": [ + { + "x": [ + "Benchmark", + "Team C", + "Team B", + "Team A", + "PD-DWI" + ], + "y": [ + 0.782, + 0.803, + 0.838, + 0.840, + 0.885 + ], + "marker": { + "color": ["rgba(136,204,238,1)", "rgba(136,204,238,1)", "rgba(136,204,238,1)", "rgba(136,204,238,1)", "rgba(204,102,119,1)"] + }, + "type": "bar" + } + ], + "layout": { + "title": "Model Performance", + "xaxis": { "title": "Best Performing Models" }, + "yaxis": { + "title": "AUC Score", + "range": [ + 0.75, + 0.9 + ] + } + } +} +``` + diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index 4f04354..0000000 --- a/docs/installation.md +++ /dev/null @@ -1,34 +0,0 @@ -# Installation - -There are two ways you can use PD-DWI - -1. Install via pip -2. Install from source - -## Install via pip - -PD-DWI package is available on PyPi for installation via pip. Wheels are automatically generated for each release of PD-DWI, allowing you to -install pd-dwi without having to compile anything. - -* Ensure that you have ``python`` installed on your machine, version 3.8 (64-bits). - -* Install PD-DWI: - - !!! info inline end "" - python -m pip install pd-dwi - -## Install from source - -PD-DWI can also be installed from source code. - -* Ensure you have `git` installed on your machine. -* Ensure you have `python` installed on your machine, version 3.8. -* Ensure you have `poetry` installed on your machine, version 1.1.11 -* Clone the repository - - !!! info inline end "" - git clone https://github.com/TechnionComputationalMRILab/PD-DWI.git - -* For unix like systems - - TBD diff --git a/docs/installation/install_package.md b/docs/installation/install_package.md new file mode 100644 index 0000000..40c587a --- /dev/null +++ b/docs/installation/install_package.md @@ -0,0 +1,34 @@ +# Install Package + +## Install via pip + +PD-DWI package is available on PyPi for installation via pip. Wheels are automatically generated for each release of PD-DWI, allowing you to +install pd-dwi without having to compile anything. + +!!! note + Ensure that you have python 3.8 installed on your machine. + +* Install PD-DWI: + ```bash + python -m pip install pd-dwi + ``` + +## Install from source + +PD-DWI can also be installed from source code. + +!!! note + Ensure the following pre-prerequisites are installed on your machine: + + * git + * python 3.8 + * poetry 1.1.11 + +1. Clone the repository +```console + git clone https://github.com/TechnionComputationalMRILab/PD-DWI.git +``` +2. Install the project +```console + cd PD-DWI & poetry install --all-extras +``` \ No newline at end of file diff --git a/docs/usage.md b/docs/usage/index.md similarity index 63% rename from docs/usage.md rename to docs/usage/index.md index 6146d0d..8e94103 100644 --- a/docs/usage.md +++ b/docs/usage/index.md @@ -1,4 +1,4 @@ -# Usage +# Getting Started ## Dataset setup @@ -34,43 +34,10 @@ To calculate the ADC and F maps from your DWI data, please use our pre-processin All clinical data will be stored in a file named _clinical.csv_. Each line will contain the following values, by order of appearance: + 1. Patient ID DICOM - subject identifier, must be identical to subject's folder name 2. hrher4g - 4 level hormone receptor status 3. SBRgrade - 3 level tumor grade 4. race - subject's race 5. Ltype - lesion type -6. pcr - pCR label of subject. If not available, should be defined as an empty string - -## Command-line usage - -All options on the command line can be listed by running: - -!!! note "" - pd-dwi -h - -### Train -To train a pd-dwi model, run: - -!!! note "" - pd-dwi train -dataset -config -out - -* The pd-dwi framework expects the dataset to be organized in a specific way, please refer to Dataset setup for additional information. -* For training configuration structure and options, please refer to training configuration documentation. - - -### Predict -To predict model output using a pre-trained pd-dwi model, run - -!!! note "" - pd-dwi predict -model -dataset [-probability] -out - -!!! warning - The pre-trained model must be trained on the same pd-dwi version as the one used for prediction - - -### Score -To evaluate the performance of the pd-dwi model, run - -!!! note "" - pd-dwi score -model -dataset - +6. pcr - pCR label of subject. If not available, should be defined as an empty string \ No newline at end of file diff --git a/docs/usage/model.md b/docs/usage/model.md new file mode 100644 index 0000000..e7ee709 --- /dev/null +++ b/docs/usage/model.md @@ -0,0 +1,11 @@ +## Model + + +## Command-line usage + +This page provides documentation for command line tools. + +::: mkdocs-click + :module: pd_dwi.scripts.cli + :command: pd_dwi_cli + :style: table diff --git a/docs/usage/pre_processing.md b/docs/usage/pre_processing.md new file mode 100644 index 0000000..c5b3dad --- /dev/null +++ b/docs/usage/pre_processing.md @@ -0,0 +1,10 @@ +# DWI Pre Processing + +## Command-line Usage + +This page provides documentation for pre-processing command line tools. + +::: mkdocs-click + :module: pd_dwi.scripts.cli + :command: preprocessing_cli + :style: table diff --git a/docs/training_configuration.md b/docs/usage/training_configuration.md similarity index 100% rename from docs/training_configuration.md rename to docs/usage/training_configuration.md diff --git a/mkdocs.yml b/mkdocs.yml index bd046e5..f883aba 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,12 +1,60 @@ site_name: PD-DWI +repo_url: https://github.com/technionComputationalMRILab/PD-DWI/ + +theme: + name: readthedocs + highlightjs: true + include_homepage_in_sidebar: true + nav: - Home: index.md - - Installation: installation.md - - Usage: usage.md - - Training Configuration: training_configuration.md - - Dataset Preprocessing: dataset_preprocessing.md + - Installation: + - Install Package: installation/install_package.md + - Usage: + - Getting Started: usage/index.md + - DWI Pre Processing: usage/pre_processing.md + - Model: usage/model.md + - Training Configuration: usage/training_configuration.md + +plugins: + - plotly + - search -theme: readthedocs markdown_extensions: + # Python Markdown + - abbr - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - toc: + permalink: true + + # Python Markdown Extensions + - pymdownx.arithmatex: + generic: true + - pymdownx.betterem: + smart_enable: all + - pymdownx.caret + - pymdownx.details + - pymdownx.highlight + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + - mkdocs-click + - pymdownx.superfences: + custom_fences: + - name: plotly + class: mkdocs-plotly + format: !!python/name:mkdocs_plotly_plugin.fences.fence_plotly + + diff --git a/poetry.lock b/poetry.lock index b6ed04d..88ff4f4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,6 +33,54 @@ tests = ["attrs", "zope-interface"] tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] tests-no-zope = ["attrs", "cloudpickle", "hypothesis", "pympler", "pytest-xdist", "pytest (>=4.3.0)"] +[[package]] +name = "babel" +version = "2.15.0" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} + +[package.extras] +dev = ["pytest (>=6.0)", "pytest-cov", "freezegun (>=1.0,<2.0)"] + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +category = "dev" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.7.0" + [[package]] name = "click" version = "8.1.7" @@ -82,6 +130,17 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "flatten-dict" +version = "0.4.2" +description = "A flexible utility for flattening and unflattening dict-like objects in Python." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +six = ">=1.12,<2.0" + [[package]] name = "ghp-import" version = "2.1.0" @@ -96,6 +155,14 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["twine", "markdown", "flake8", "wheel"] +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" +optional = false +python-versions = ">=3.5" + [[package]] name = "importlib-metadata" version = "8.0.0" @@ -159,7 +226,7 @@ python-versions = ">=3.8" [[package]] name = "jsonschema" -version = "4.22.0" +version = "4.23.0" description = "An implementation of JSON Schema validation for Python" category = "main" optional = false @@ -175,7 +242,7 @@ rpds-py = ">=0.7.1" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] [[package]] name = "jsonschema-specifications" @@ -248,6 +315,18 @@ watchdog = ">=2.0" i18n = ["babel (>=2.9.0)"] min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml-env-tag (==0.1)", "pyyaml (==5.1)", "watchdog (==2.0)"] +[[package]] +name = "mkdocs-click" +version = "0.8.1" +description = "An MkDocs extension to generate documentation for Click command line applications" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.1" +markdown = ">=3.3" + [[package]] name = "mkdocs-get-deps" version = "0.2.0" @@ -262,6 +341,54 @@ mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" +[[package]] +name = "mkdocs-material" +version = "9.5.28" +description = "Documentation that simply works" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +babel = ">=2.10,<3.0" +colorama = ">=0.4,<1.0" +jinja2 = ">=3.0,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.6,<2.0" +mkdocs-material-extensions = ">=1.3,<2.0" +paginate = ">=0.5,<1.0" +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +regex = ">=2022.4" +requests = ">=2.26,<3.0" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +category = "dev" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "mkdocs-plotly-plugin" +version = "0.1.3" +description = "MkDocs plugin to add plotly charts from plotly's json data" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +beautifulsoup4 = ">=4.11.1" +flatten-dict = ">=0.4.2" +mkdocs = ">=1.1" +pymdown-extensions = ">=9.2" + [[package]] name = "mypy" version = "1.10.1" @@ -305,6 +432,14 @@ category = "dev" optional = false python-versions = ">=3.8" +[[package]] +name = "paginate" +version = "0.5.6" +description = "Divides large result sets into pages for easier browsing" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "pandas" version = "1.5.3" @@ -372,7 +507,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pydantic" -version = "2.7.4" +version = "2.8.2" description = "Data validation using Python type hints" category = "main" optional = false @@ -380,15 +515,15 @@ python-versions = ">=3.8" [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.4" -typing-extensions = ">=4.6.1" +pydantic-core = "2.20.1" +typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} [package.extras] email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.4" +version = "2.20.1" description = "Core functionality for Pydantic validation and serialization" category = "main" optional = false @@ -426,6 +561,17 @@ python-versions = ">=3.7" [package.extras] docs = ["numpy", "numpydoc", "matplotlib", "pillow", "sphinx", "sphinx-rtd-theme", "sphinx-gallery", "sphinxcontrib-napoleon", "sphinx-copybutton"] +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pykwalify" version = "1.8.0" @@ -439,6 +585,21 @@ docopt = ">=0.6.2" python-dateutil = ">=2.8.0" "ruamel.yaml" = ">=0.16.0" +[[package]] +name = "pymdown-extensions" +version = "10.8.1" +description = "Extension pack for Python Markdown." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + [[package]] name = "pyradiomics" version = "3.1.0" @@ -577,9 +738,35 @@ python-versions = ">=3.8" attrs = ">=22.2.0" rpds-py = ">=0.7.0" +[[package]] +name = "regex" +version = "2024.5.15" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "rpds-py" -version = "0.18.1" +version = "0.19.0" description = "Python bindings to Rust's persistent data structures (rpds)" category = "main" optional = false @@ -660,6 +847,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "dev" +optional = false +python-versions = ">=3.8" + [[package]] name = "strenum" version = "0.4.15" @@ -673,6 +868,20 @@ docs = ["sphinx", "sphinx-rtd-theme", "myst-parser"] release = ["twine"] test = ["pytest", "pytest-black", "pytest-cov", "pytest-pylint", "pylint"] +[[package]] +name = "termynal" +version = "0.12.1" +description = "A lightweight and modern animated terminal window" +category = "dev" +optional = false +python-versions = ">=3.8.1,<4.0.0" + +[package.dependencies] +markdown = "*" + +[package.extras] +mkdocs = ["mkdocs (>=1.4,<2.0)"] + [[package]] name = "threadpoolctl" version = "3.5.0" @@ -705,6 +914,20 @@ category = "main" optional = false python-versions = ">=3.8" +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "watchdog" version = "4.0.1" @@ -753,8 +976,8 @@ preprocessing = ["pydicom"] [metadata] lock-version = "1.1" -python-versions = ">=3.8,<3.9" -content-hash = "92ad4dc206521d3ef3dfa2170f8c2fbe6402cb558fc3ab0536e29ac73a9dc8cc" +python-versions = ">=3.8.1,<3.9" +content-hash = "7e5afd7fe210bf2866791a5ceb3d255522a625d76b49e377ee6eeeadc8f76d3a" [metadata.files] annotated-types = [] @@ -762,6 +985,10 @@ atomicwrites = [ {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, ] attrs = [] +babel = [] +beautifulsoup4 = [] +certifi = [] +charset-normalizer = [] click = [] colorama = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -770,7 +997,9 @@ colorama = [ coverage = [] cython = [] docopt = [] +flatten-dict = [] ghp-import = [] +idna = [] importlib-metadata = [] importlib-resources = [] iniconfig = [] @@ -782,11 +1011,16 @@ markdown = [] markupsafe = [] mergedeep = [] mkdocs = [] +mkdocs-click = [] mkdocs-get-deps = [] +mkdocs-material = [] +mkdocs-material-extensions = [] +mkdocs-plotly-plugin = [] mypy = [] mypy-extensions = [] numpy = [] packaging = [] +paginate = [] pandas = [] pathspec = [] pkgutil-resolve-name = [ @@ -803,7 +1037,9 @@ pydantic = [] pydantic-core = [] pydantic-yaml = [] pydicom = [] +pygments = [] pykwalify = [] +pymdown-extensions = [] pyradiomics = [] pytest = [] pytest-cov = [] @@ -815,6 +1051,8 @@ pywavelets = [] pyyaml = [] pyyaml-env-tag = [] referencing = [] +regex = [] +requests = [] rpds-py = [] "ruamel.yaml" = [] "ruamel.yaml.clib" = [] @@ -847,7 +1085,9 @@ six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +soupsieve = [] strenum = [] +termynal = [] threadpoolctl = [] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, @@ -855,6 +1095,7 @@ tomli = [ ] types-pyyaml = [] typing-extensions = [] +urllib3 = [] watchdog = [] xgboost = [] zipp = [] diff --git a/pyproject.toml b/pyproject.toml index 2f01c0a..116b452 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pd-dwi" -version = "1.1.2" +version = "1.1.3" description = "Physiologically-Decomposed Diffusion-Weighted MRI machine-learning model for predicting response to neoadjuvant chemotherapy in invasive breast cancer" authors = [ "Maya Gilad " @@ -27,7 +27,7 @@ pd-dwi-preprocessing = "pd_dwi.scripts.cli:preprocessing_cli" [tool.poetry.dependencies] -python = ">=3.8,<3.9" +python = ">=3.8.1,<3.9" Cython = "^0.29.30" jsonschema = "^4.6.0" numpy = "^1.22.0" @@ -44,11 +44,16 @@ pydicom = "^2.4.4" [tool.poetry.dev-dependencies] pytest = "7.1.2" pytest-cov = "^4.1.0" +pytest-mock = "^3.12.0" pytest-subtests = "^0.12.1" mypy = "^1.9.0" types-PyYAML = "^6.0.12" -pytest-mock = "^3.12.0" mkdocs = "^1.6.0" +mkdocs-plotly-plugin = "^0.1.3" +pymdown-extensions = "^10.8.1" +mkdocs-click = "^0.8.1" +mkdocs-material = "^9.5.28" +termynal = "^0.12.1" [tool.poetry.extras] preprocessing = ["pydicom"] diff --git a/src/pd_dwi/preprocessing/adc.py b/src/pd_dwi/preprocessing/adc.py index a098bef..eda1aa4 100644 --- a/src/pd_dwi/preprocessing/adc.py +++ b/src/pd_dwi/preprocessing/adc.py @@ -142,13 +142,13 @@ def calculate_adc_from_files(*dwi_file_paths) -> np.ndarray: adc_shape = dwi_arrays[0].shape adc_data = np.zeros(adc_shape, dtype=np.float64) for slice_idx in range(adc_shape[0]): - _, slope = calculate_adc_slice(b_values, list(map(itemgetter(slice_idx), dwi_observations))) + _, slope = calculate_adc_slice(b_values, list(map(itemgetter(slice_idx), dwi_arrays))) adc_data[slice_idx] = slope return adc_data -def calculate_adc(dwi_input_folder: str, b_values: Set[int], output_folder: str) -> np.ndarray: +def calculate_adc(dwi_input_folder: str, b_values: Set[int], output_folder: Optional[str] = None) -> np.ndarray: """ Calculates ADC data from DWI acquisition and set of input b-values @@ -161,7 +161,7 @@ def calculate_adc(dwi_input_folder: str, b_values: Set[int], output_folder: str) dwi_file_paths = _find_dwi_files(dwi_input_folder, b_values) adc_data = calculate_adc_from_files(*dwi_file_paths) - if output_folder is not None: + if output_folder: _save_adc(adc_data, b_values, dwi_file_paths[0], output_folder, comments="Created using Least-Squares Line Fit (matrices implementation)") diff --git a/src/pd_dwi/scripts/adc_preprocess_command.py b/src/pd_dwi/scripts/adc_preprocess_command.py index 7457b34..0dfbc73 100644 --- a/src/pd_dwi/scripts/adc_preprocess_command.py +++ b/src/pd_dwi/scripts/adc_preprocess_command.py @@ -1,24 +1,24 @@ import os.path -from pd_dwi.preprocessing.adc import calculate_adc +import click +from pd_dwi.preprocessing.adc import calculate_adc -def adc_preprocess(args): - input_data = args.dwi_data - b_values = args.b - if os.path.isdir(input_data): - calculate_adc(input_data, set(b_values), input_data) +@click.command(name='adc', + help="Calculates an ADC from input DWI sequences and saves it in DWI folder." + "DATA_PATH can be used to run in either single or bulk mode." + "A text file path will enable the bulk mode.", + ) +@click.argument('data_path', type=click.Path(exists=True, file_okay=False)) +@click.argument('b', nargs=-1, required=True, type=click.IntRange(min=0)) +def adc_preprocess(data_path, b): + if os.path.isdir(data_path): + calculate_adc(data_path, set(b), data_path) else: - with open(input_data, mode='r') as f: + print("Entering bulk mode.") + with open(data_path, mode='r') as f: # Skip first line f.readline() for dwi_folder in f.readlines(): - calculate_adc(dwi_folder, set(b_values), dwi_folder) - - -def add_adc_parser(parser) -> None: - parser.add_argument('-dwi_data', type=str, required=True, help="Path for DWI acquisition folder or input file listing DWI folders") - parser.add_argument('-b', type=int, nargs='+', required=True, help="B-values to use for ADC calculation") - - parser.set_defaults(func=adc_preprocess) + calculate_adc(dwi_folder, set(b), dwi_folder) diff --git a/src/pd_dwi/scripts/cli.py b/src/pd_dwi/scripts/cli.py index 81969c8..4e6466e 100644 --- a/src/pd_dwi/scripts/cli.py +++ b/src/pd_dwi/scripts/cli.py @@ -1,36 +1,28 @@ -import argparse -from typing import List, Optional +import click -from pd_dwi.scripts.adc_preprocess_command import add_adc_parser -from pd_dwi.scripts.list_command import add_list_parser -from pd_dwi.scripts.predict_command import add_predict_parser -from pd_dwi.scripts.score_command import add_score_parser -from pd_dwi.scripts.train_command import add_train_parser +from pd_dwi.scripts.adc_preprocess_command import adc_preprocess +from pd_dwi.scripts.list_command import list_available_models +from pd_dwi.scripts.predict_command import predict +from pd_dwi.scripts.score_command import score +from pd_dwi.scripts.train_command import train -def pd_dwi_cli(input_args: Optional[List[str]] = None) -> None: - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest='cmd', required=True) +@click.group(name='pd-dwi') +@click.version_option(message='version %(version)s') +def pd_dwi_cli() -> None: + pass - add_train_parser(subparsers.add_parser('train')) - add_predict_parser(subparsers.add_parser('predict')) - add_score_parser(subparsers.add_parser('score')) - add_list_parser(subparsers.add_parser('list')) - args = parser.parse_args(input_args) - args.func(args) +pd_dwi_cli.add_command(list_available_models) +pd_dwi_cli.add_command(predict) +pd_dwi_cli.add_command(score) +pd_dwi_cli.add_command(train) -def preprocessing_cli(input_args: Optional[List[str]] = None) -> None: - parser = argparse.ArgumentParser() +@click.group(name='pd-dwi-preprocessing') +@click.version_option(message='version %(version)s') +def preprocessing_cli() -> None: + pass - subparsers = parser.add_subparsers(dest='cmd', required=True) - add_adc_parser(subparsers.add_parser('adc')) - - args = parser.parse_args(input_args) - args.func(args) - - -if __name__ == '__main__': - pd_dwi_cli() +preprocessing_cli.add_command(adc_preprocess) diff --git a/src/pd_dwi/scripts/list_command.py b/src/pd_dwi/scripts/list_command.py index 4f53182..736e209 100644 --- a/src/pd_dwi/scripts/list_command.py +++ b/src/pd_dwi/scripts/list_command.py @@ -1,15 +1,13 @@ -import argparse -from typing import Any +import click from pd_dwi.models import get_available_models -def list_available_models(args: Any) -> None: +@click.command(name='list') +def list_available_models() -> None: + """ Lists pre-trained model that are installed within the package. """ models = get_available_models() for model_name, description in models.items(): print(f'* {model_name}:\n\t{description}') - -def add_list_parser(parser) -> None: - parser.set_defaults(func=list_available_models) diff --git a/src/pd_dwi/scripts/predict_command.py b/src/pd_dwi/scripts/predict_command.py index caf5d53..79d4447 100644 --- a/src/pd_dwi/scripts/predict_command.py +++ b/src/pd_dwi/scripts/predict_command.py @@ -1,37 +1,24 @@ -import os.path -from sys import stderr +import click from pd_dwi.model import Model -from pd_dwi.models import get_model_path_by_name -def predict(args): - if args.out: - assert args.out.endswith('.csv') - - if os.path.exists(args.model): - model_path = args.model - else: - model_path = get_model_path_by_name(args.model) - - if model_path is None: - print(f'Could not locate {args.model}, please check model exists', file=stderr) - exit(1) +@click.command() +@click.argument('model_path', type=click.Path(exists=True, dir_okay=False)) +@click.argument('dataset', type=click.Path(exists=True, file_okay=False)) +@click.option('-probability', is_flag=True, type=bool, help="Retrieve prediction probability") +@click.option('-out', type=str, help="Path for prediction results file") +def predict(model_path, dataset, probability, out): + """ Calculate model prediction for all subjects in input dataset. + MODEL_PATH is the location of the model to use. + """ model = Model.load(model_path) - f_predict = model.predict_proba if args.probability else model.predict - y_pred = f_predict(args.dataset) + f_predict = model.predict_proba if probability else model.predict + y_pred = f_predict(dataset) - if args.out: - y_pred.to_csv(args.out) + if out: + y_pred.to_csv(out) else: print(y_pred) - - -def add_predict_parser(parser): - parser.add_argument('-model', type=str, required=True, help="Path for model name to load") - parser.add_argument('-dataset', type=str, required=True, help="Path for dataset to use") - parser.add_argument('-probability', action='store_true', help="Should retrieve prediction probability") - parser.add_argument('-out', type=str, required=True, help="Path for prediction results file") - parser.set_defaults(func=predict) diff --git a/src/pd_dwi/scripts/score_command.py b/src/pd_dwi/scripts/score_command.py index a55183e..6e45551 100644 --- a/src/pd_dwi/scripts/score_command.py +++ b/src/pd_dwi/scripts/score_command.py @@ -1,12 +1,12 @@ +import click + from pd_dwi.model import Model -def score(args): - score = Model.load(args.model).score(args.dataset) +@click.command() +@click.argument('model_path', type=click.Path(exists=True, dir_okay=False)) +@click.argument('dataset', type=click.Path(exists=True, file_okay=False)) +def score(model_path, dataset): + """ Evaluate model performance on a given dataset. """ + score = Model.load(model_path).score(dataset) print(f'{score:.4f}') - - -def add_score_parser(parser): - parser.add_argument('-model', type=str, required=True, help="Path for model name to load") - parser.add_argument('-dataset', type=str, required=True, help="Path for dataset to use") - parser.set_defaults(func=score) diff --git a/src/pd_dwi/scripts/train_command.py b/src/pd_dwi/scripts/train_command.py index bb1a52c..ffa5d64 100644 --- a/src/pd_dwi/scripts/train_command.py +++ b/src/pd_dwi/scripts/train_command.py @@ -1,14 +1,14 @@ -from pd_dwi.model import Model - +import click -def train(args): - model = Model.from_config(args.config) - model.train(args.dataset) - model.save(args.out) +from pd_dwi.model import Model -def add_train_parser(parser): - parser.add_argument('-dataset', type=str, required=True, help='Path for dataset to use') - parser.add_argument('-config', type=open, required=True, help='Path for config to use') - parser.add_argument('-out', type=str, required=True, help='Path for storing the trained model') - parser.set_defaults(func=train) +@click.command() +@click.argument('dataset', type=click.Path(exists=True, file_okay=False)) +@click.argument('config', type=click.Path(exists=True, dir_okay=False)) +@click.argument('out', type=click.Path(exists=False)) +def train(dataset, config, out): + """ Train a new model. """ + model = Model.from_config(config) + model.train(dataset) + model.save(out) diff --git a/tests/test_cli.py b/tests/test_cli.py index 60a46a4..6fa447c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,25 +1,27 @@ -from subprocess import run +from click.testing import CliRunner -import pytest - -from pd_dwi.scripts.cli import pd_dwi_cli +from pd_dwi.scripts.cli import pd_dwi_cli, preprocessing_cli def test_cli_available(): - p = run(["pd-dwi", "--help"]) - assert not p.returncode + p = CliRunner().invoke(pd_dwi_cli, args=['--help']) + assert not p.exit_code def test_list(): - pd_dwi_cli(["list"]) + CliRunner().invoke(pd_dwi_cli, args=['list']) def test_invalid_command(): - with pytest.raises(SystemExit): - pd_dwi_cli(["cmd"]) + p = CliRunner().invoke(pd_dwi_cli, args=['cmd']) + assert p.exit_code == 2 + + +def test_predict(): + p = CliRunner().invoke(pd_dwi_cli, args=['predict']) + assert p.exit_code == 2 -def test_predict(subtests): - with subtests.test("missing arguments"): - with pytest.raises(SystemExit): - pd_dwi_cli(["predict"]) +def test_preprocessing_adc(): + p = CliRunner().invoke(preprocessing_cli, args=['adc']) + assert p.exit_code == 2 \ No newline at end of file diff --git a/tests/test_preprocessing_adc.py b/tests/test_preprocessing_adc.py index a89b7a7..0ab2195 100644 --- a/tests/test_preprocessing_adc.py +++ b/tests/test_preprocessing_adc.py @@ -1,6 +1,12 @@ +import os.path +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock, MagicMock + import numpy as np +from pydicom import FileDataset, DataElement -from pd_dwi.preprocessing.adc import calculate_adc_slice +from pd_dwi.preprocessing.adc import calculate_adc_slice, calculate_adc def test_adc_fit(): @@ -19,9 +25,30 @@ def test_adc_fit(): assert np.all(np.isclose(out_ADC, syntentic_adc)) -def _adc_formula(s0, adc_model, b_value): - return s0 * np.exp(-b_value * adc_model) +def test_calculate_adc(mocker): + volume_shape = (100, 2, 2) + + b_values = {0, 100, 500} + + with TemporaryDirectory() as temp_dir: + b_value_files = [os.path.join(temp_dir, f'DWI-{b}.dcm') for b in b_values] + + mocker.patch('pd_dwi.preprocessing.adc._find_dwi_files', return_value=b_value_files) + def dcm_mock(path): + mock = MagicMock(spec=FileDataset) + mock.pixel_array = np.array(np.random.randint(500, 2000, volume_shape), dtype=np.float64) + mock.filename = Path(path).name + return mock -def _pred_pixel(s0_model, adc_model, row=0, col=0): - return [_adc_formula(s0_model, adc_model, b_value)[row, col] for b_value in b_vector] \ No newline at end of file + def b_value_mock(dcm): + return int(dcm.filename.split('-')[1].replace('.dcm', '')) + + mocker.patch('pd_dwi.preprocessing.adc.dcmread', side_effect=dcm_mock) + mocker.patch('pd_dwi.preprocessing.adc.read_b_value', side_effect=b_value_mock) + + calculate_adc(temp_dir, b_values=b_values) + + +def _adc_formula(s0, adc_model, b_value): + return s0 * np.exp(-b_value * adc_model) \ No newline at end of file